Snow outgoing webhooks tab (#3918)

# What this PR does
- implement visual part of ServiceNow Outgoing Tab
- reuse created components on outgoing webhooks page
- make yarn:lint:fix to remove unused imports
- fix live reload during development
- remove unused babel dependencies

## Which issue(s) this PR fixes
- https://github.com/grafana/oncall/issues/3408
- partially https://github.com/grafana/oncall-private/issues/2462

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Dominik Broj 2024-02-26 14:52:26 +01:00 committed by GitHub
parent 19b5c6553c
commit c2ffd28675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1913 additions and 1532 deletions

View file

@ -3,7 +3,7 @@ rulesDirPlugin.RULES_DIR = 'tools/eslint-rules';
module.exports = {
extends: ['./.config/.eslintrc'],
plugins: ['rulesdir', 'import'],
plugins: ['rulesdir', 'import', 'unused-imports'],
settings: {
'import/internal-regex':
'^assets|^components|^containers|^contexts|^icons|^models|^network|^pages|^services|^state|^utils|^plugin',
@ -37,7 +37,9 @@ module.exports = {
},
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': [
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': ['warn'],
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',

View file

@ -40,7 +40,7 @@ test.describe('maintenance mode works', () => {
const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise<void> => {
await _openIntegrationSettingsPopup(page, true);
// open the maintenance mode settings drawer + fill in the maintenance details
await page.getByTestId('integration-start-maintenance').click();
await page.getByText('Start Maintenance').click();
// fill in the form
const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer');
@ -78,7 +78,7 @@ test.describe('maintenance mode works', () => {
await _openIntegrationSettingsPopup(page, true);
// click the stop maintenance button
await page.getByTestId('integration-stop-maintenance').click();
await page.getByText('Stop Maintenance').click();
// in the modal popup, confirm that we want to stop it
await clickButton({

View file

@ -40,7 +40,7 @@ test('create advanced webhook and check it is displayed on the list correctly',
await webhooksFormDivs.locator('.monaco-editor').first().click();
await page.keyboard.insertText(WEBHOOK_URL);
await clickButton({ page, buttonText: 'Create Webhook' });
await clickButton({ page, buttonText: 'Create' });
await checkWebhookPresenceInTable({ page, webhookName: WEBHOOK_NAME, expectedTriggerType: 'Resolved' });
});

View file

@ -18,7 +18,7 @@ test('create simple webhook and check it is displayed on the list correctly', as
await page.locator('[name=name]').fill(WEBHOOK_NAME);
await page.getByLabel('New Outgoing Webhook').getByRole('img').nth(1).click(); // Open team dropdown
await page.getByLabel('Select options menu').getByText('No team').click();
await clickButton({ page, buttonText: 'Create Webhook' });
await clickButton({ page, buttonText: 'Create' });
await checkWebhookPresenceInTable({ page, webhookName: WEBHOOK_NAME, expectedTriggerType: 'Escalation step' });
});

View file

@ -4,7 +4,7 @@
"description": "Grafana OnCall Plugin",
"scripts": {
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --quiet ./src ./e2e-tests",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx ./src ./e2e-tests",
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
"build": "webpack -c ./webpack.config.ts --env production",
@ -19,7 +19,6 @@
"test:e2e:gen": "yarn playwright codegen http://localhost:3000",
"e2e-show-report": "yarn playwright show-report",
"generate-types": "cd ./src/network/oncall-api/types-generator && yarn generate",
"dev": "webpack -c ./webpack.config.ts --env development",
"watch": "webpack -w -c ./webpack.config.ts --env development",
"sign": "npx --yes @grafana/sign-plugin@latest",
"start": "yarn watch",
@ -47,21 +46,6 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"devDependencies": {
"@babel/core": "^7.21.4",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.20.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.18.9",
"@babel/plugin-proposal-optional-chaining": "^7.18.9",
"@babel/plugin-syntax-decorators": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-destructuring": "^7.20.0",
"@babel/plugin-transform-react-constant-elements": "^7.18.12",
"@babel/plugin-transform-runtime": "^7.19.6",
"@babel/plugin-transform-typescript": "^7.18.12",
"@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@grafana/eslint-config": "^6.0.0",
"@grafana/tsconfig": "^1.2.0-rc1",
"@jest/globals": "^27.5.1",
@ -87,7 +71,6 @@
"@types/testing-library__jest-dom": "5.14.8",
"@types/throttle-debounce": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^5.40.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"dompurify": "^2.3.12",
@ -98,6 +81,7 @@
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-rulesdir": "^0.2.1",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-webpack-plugin": "^4.0.1",
"fork-ts-checker-webpack-plugin": "^8.0.0",
"glob": "^10.2.7",

View file

@ -0,0 +1,25 @@
import React, { FC } from 'react';
import { IconButton } from '@grafana/ui';
import CopyToClipboard from 'react-copy-to-clipboard';
import { openNotification } from 'utils/utils';
interface CopyToClipboardProps {
text: string;
iconButtonProps?: Partial<Parameters<typeof IconButton>[0]>;
}
const CopyToClipboardIcon: FC<CopyToClipboardProps> = ({ text, iconButtonProps }) => {
const onCopy = () => {
openNotification('Copied to clipboard');
};
return (
<CopyToClipboard text={text} onCopy={onCopy}>
<IconButton aria-label="Copy" name="copy" {...iconButtonProps} />
</CopyToClipboard>
);
};
export default CopyToClipboardIcon;

View file

@ -0,0 +1,77 @@
import React, { FC, ReactNode } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { HamburgerMenuIcon } from 'components/HamburgerMenuIcon/HamburgerMenuIcon';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { isUserActionAllowed, UserAction } from 'utils/authorization/authorization';
interface HamburgerContextMenuProps {
items: Array<
{ onClick?: () => void; label: ReactNode; requiredPermission?: UserAction; hidden?: boolean } | 'divider'
>;
hamburgerIconClassName?: string;
}
export const HamburgerContextMenu: FC<HamburgerContextMenuProps> = ({ items, hamburgerIconClassName }) => {
const styles = useStyles2(getStyles);
return (
<WithContextMenu
renderMenuItems={() => (
<div className={styles.menuList}>
{items.map((item, idx) => {
if (item === 'divider') {
return <div key="line-break" className="thin-line-break" />;
} else if (item.hidden) {
return null;
}
return item.requiredPermission ? (
<WithPermissionControlTooltip key={idx} userAction={item.requiredPermission}>
<div
className={styles.menuItem}
key={idx}
onClick={isUserActionAllowed(item.requiredPermission) && item.onClick}
>
{item.label}
</div>
</WithPermissionControlTooltip>
) : (
<div className={styles.menuItem} key={idx} onClick={item.onClick}>
{item.label}
</div>
);
})}
</div>
)}
>
{({ openMenu }) => (
<HamburgerMenuIcon openMenu={openMenu} listBorder={2} listWidth={225} className={hamburgerIconClassName} />
)}
</WithContextMenu>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
menuList: css({
display: 'flex',
flexDirection: 'column',
width: '225px',
borderRadius: '2px',
}),
menuItem: css({
padding: '8px',
whiteSpace: 'nowrap',
borderLeft: '2px solid transparent',
minWidth: '84px',
gap: '8px',
cursor: 'pointer',
'&:hover': {
background: theme.colors.background.secondary,
},
}),
});

View file

@ -3,9 +3,9 @@ import React, { useRef } from 'react';
import { Icon } from '@grafana/ui';
import cn from 'classnames/bind';
import styles from './HamburgerMenu.module.scss';
import styles from './HamburgerMenuIcon.module.scss';
interface HamburgerMenuProps {
interface HamburgerMenuIconProps {
openMenu: React.MouseEventHandler<HTMLElement>;
listWidth: number;
listBorder: number;
@ -16,7 +16,7 @@ interface HamburgerMenuProps {
const cx = cn.bind(styles);
export const HamburgerMenu: React.FC<HamburgerMenuProps> = (props) => {
export const HamburgerMenuIcon: React.FC<HamburgerMenuIconProps> = (props) => {
const ref = useRef<HTMLDivElement>();
const { openMenu, listBorder, listWidth, withBackground, className, stopPropagation = false } = props;
return (

View file

@ -14,12 +14,11 @@ const cx = cn.bind(styles);
export interface IntegrationCollapsibleItem {
isHidden?: boolean;
customIcon?: IconName;
canHoverIcon: boolean;
canHoverIcon?: boolean;
isTextIcon?: boolean;
collapsedView: (toggle?: () => void) => React.ReactNode; // needs toggle param for toggling on click
collapsedView?: (toggle?: () => void) => React.ReactNode; // needs toggle param for toggling on click
expandedView: () => React.ReactNode; // for consistency, this is also a function
isCollapsible: boolean;
iconText?: string;
isCollapsible?: boolean;
isExpanded?: boolean;
startingElemPosition?: string;
onStateChange?(isChecked: boolean): void;

View file

@ -18,7 +18,7 @@ import { observer } from 'mobx-react';
import { GTable } from 'components/GTable/GTable';
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { Tag } from 'components/Tag/Tag';
import IntegrationTag from 'components/Integrations/IntegrationTag';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
@ -26,7 +26,6 @@ import { ContactPoint } from 'models/alert_receive_channel/alert_receive_channel
import { ApiSchemas } from 'network/oncall-api/api.types';
import styles from 'pages/integration/Integration.module.scss';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
import { openErrorNotification, openNotification } from 'utils/utils';
const cx = cn.bind(styles);
@ -145,11 +144,7 @@ export const IntegrationContactPoint: React.FC<{
)}
<HorizontalGroup spacing="md">
<Tag color={getVar('--tag-secondary-transparent')} border={getVar('--border-weak')} className={cx('tag')}>
<Text type="primary" size="small" className={cx('radius')}>
Contact point
</Text>
</Tag>
<IntegrationTag>Contact point</IntegrationTag>
{contactPoints?.length ? (
<HorizontalGroup>

View file

@ -6,12 +6,11 @@ import { noop } from 'lodash-es';
import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField';
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { Tag } from 'components/Tag/Tag';
import { IntegrationTag } from 'components/Integrations/IntegrationTag';
import { Text } from 'components/Text/Text';
import { ApiSchemas } from 'network/oncall-api/api.types';
import styles from 'pages/integration/Integration.module.scss';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
const cx = cn.bind(styles);
@ -40,11 +39,7 @@ export const IntegrationHowToConnect: React.FC<{ id: ApiSchemas['AlertReceiveCha
toggle={noop}
heading={
<div className={cx('how-to-connect__container')}>
<Tag color={getVar('--tag-secondary-transparent')} border={getVar('--border-weak')} className={cx('tag')}>
<Text type="primary" size="small" className={cx('radius')}>
{howToConnectTagName(item?.integration)}
</Text>
</Tag>
<IntegrationTag>{howToConnectTagName(item?.integration)}</IntegrationTag>
{item?.integration === 'direct_paging' ? (
<>
<Text type="secondary">Alert Groups raised manually via Web or ChatOps</Text>

View file

@ -2,9 +2,8 @@ import React, { useState } from 'react';
import { HorizontalGroup, IconButton, Input } from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import { openNotification } from 'utils/utils';
import CopyToClipboardIcon from 'components/CopyToClipboardIcon/CopyToClipboardIcon';
import styles from './IntegrationInputField.module.scss';
@ -36,11 +35,7 @@ export const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
<div className={cx('icons')}>
<HorizontalGroup spacing={'xs'}>
{showEye && <IconButton aria-label="Reveal" name={'eye'} size={'xs'} onClick={onInputReveal} />}
{showCopy && (
<CopyToClipboard text={value} onCopy={onCopy}>
<IconButton aria-label="Copy" name={'copy'} size={'xs'} />
</CopyToClipboard>
)}
{showCopy && <CopyToClipboardIcon text={value} iconButtonProps={{ size: 'xs' }} />}
{showExternal && <IconButton aria-label="Open" name={'external-link-alt'} size={'xs'} onClick={onOpen} />}
</HorizontalGroup>
</div>
@ -55,10 +50,6 @@ export const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
setIsMasked(!isInputMasked);
}
function onCopy() {
openNotification("Integration's HTTP Endpoint is copied");
}
function onOpen() {
window.open(value, '_blank');
}

View file

@ -8,7 +8,7 @@ import { logoCoors } from './IntegrationLogo.config';
import styles from 'components/IntegrationLogo/IntegrationLogo.module.css';
interface IntegrationLogoProps {
export interface IntegrationLogoProps {
integration: SelectOption;
scale: number;
}

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { HorizontalGroup } from '@grafana/ui';
import { Text } from 'components/Text/Text';
import { IntegrationLogo, IntegrationLogoProps } from './IntegrationLogo';
interface IntegrationLogoWithTitleProps {
integration: IntegrationLogoProps['integration'];
}
const IntegrationLogoWithTitle: FC<IntegrationLogoWithTitleProps> = ({ integration }) => (
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="primary">{integration?.display_name}</Text>
</HorizontalGroup>
);
export default IntegrationLogoWithTitle;

View file

@ -11,9 +11,9 @@ const cx = cn.bind(styles);
interface IntegrationBlockProps {
className?: string;
noContent: boolean;
noContent?: boolean;
heading: React.ReactNode;
content: React.ReactNode;
content?: React.ReactNode;
toggle?: () => void;
}

View file

@ -0,0 +1,37 @@
import React, { FC } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Tag } from 'components/Tag/Tag';
import { Text } from 'components/Text/Text';
interface IntegrationTagProps {
children: React.ReactNode;
}
export const IntegrationTag: FC<IntegrationTagProps> = ({ children }) => {
const styles = useStyles2(getStyles);
return (
<Tag className={styles.tag}>
<Text type="primary" size="small" className={styles.radius}>
{children}
</Text>
</Tag>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
tag: css({
height: '25px',
background: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.weak}`,
}),
radius: css({
borderRadius: '4px',
}),
});
export default IntegrationTag;

View file

@ -1,6 +1,7 @@
import React, { FC, useCallback } from 'react';
import React, { ComponentProps, FC, useCallback } from 'react';
import { CodeEditor, CodeEditorSuggestionItemKind, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames';
import { getPaths } from 'utils/utils';
@ -21,6 +22,8 @@ interface MonacoEditorProps {
loading?: boolean;
monacoOptions?: any;
suggestionPrefix?: string;
containerClassName?: string;
codeEditorProps?: Partial<ComponentProps<typeof CodeEditor>>;
}
export enum MONACO_LANGUAGE {
@ -51,6 +54,8 @@ export const MonacoEditor: FC<MonacoEditorProps> = (props) => {
showLineNumbers = true,
loading = false,
suggestionPrefix = 'payload.',
containerClassName,
codeEditorProps,
} = props;
const autoCompleteList = useCallback(
@ -100,7 +105,8 @@ export const MonacoEditor: FC<MonacoEditorProps> = (props) => {
height={height}
onEditorDidMount={handleMount}
getSuggestions={useAutoCompleteList ? autoCompleteList : undefined}
containerStyles="u-width-height-100"
containerStyles={cn('u-width-height-100', containerClassName)}
{...codeEditorProps}
/>
);
};

View file

@ -1,9 +1,10 @@
import React, { FC } from 'react';
import { Button, IconButton, Tooltip } from '@grafana/ui';
import { Button, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import { formatSourceCodeJsonString } from 'utils/string';
import { openNotification } from 'utils/utils';
import styles from './SourceCode.module.scss';
@ -14,33 +15,38 @@ interface SourceCodeProps {
noMaxHeight?: boolean;
showClipboardIconOnly?: boolean;
showCopyToClipboard?: boolean;
children?: any;
children?: string;
className?: string;
prettifyJsonString?: boolean;
}
export const SourceCode: FC<SourceCodeProps> = (props) => {
const { children, noMaxHeight = false, showClipboardIconOnly = false, showCopyToClipboard = true, className } = props;
export const SourceCode: FC<SourceCodeProps> = ({
children,
noMaxHeight = false,
showClipboardIconOnly = false,
showCopyToClipboard = true,
className,
prettifyJsonString,
}) => {
const showClipboardCopy = showClipboardIconOnly || showCopyToClipboard;
return (
<div className={cx('root')}>
{showClipboardCopy && (
<CopyToClipboard
text={children as string}
text={children}
onCopy={() => {
openNotification('Copied!');
}}
>
{showClipboardIconOnly ? (
<Tooltip placement="top" content="Copy to Clipboard">
<IconButton
aria-label="Copy"
className={cx('copyIcon')}
size={'lg'}
name="copy"
data-testid="test__copyIcon"
/>
</Tooltip>
<IconButton
aria-label="Copy"
className={cx('copyIcon')}
size={'lg'}
name="copy"
data-testid="test__copyIcon"
/>
) : (
<Button
className={cx('copyButton')}
@ -63,7 +69,7 @@ export const SourceCode: FC<SourceCodeProps> = (props) => {
className
)}
>
<code>{children}</code>
<code>{prettifyJsonString ? formatSourceCodeJsonString(children) : children}</code>
</pre>
</div>
);

View file

@ -1,9 +1,11 @@
import React, { FC, useState } from 'react';
import React, { FC, useEffect, useState } from 'react';
import { css } from '@emotion/css';
import { Tab, TabsBar, TabContent, useStyles2 } from '@grafana/ui';
import cn from 'classnames';
import { LocationHelper } from 'utils/LocationHelper';
interface TabConfig {
label: string;
content: React.ReactNode;
@ -11,24 +13,45 @@ interface TabConfig {
interface TabsProps {
tabs: TabConfig[];
defaultActiveLabel?: string;
tabContentClassName?: string;
shouldBeSyncedWithQueryString?: boolean;
// in case there are more than 1 <Tabs /> in the page, we want to use different queryString keys
queryStringKey?: string;
}
export const Tabs: FC<TabsProps> = ({ tabs, defaultActiveLabel, tabContentClassName }) => {
export const Tabs: FC<TabsProps> = ({
tabs,
tabContentClassName,
shouldBeSyncedWithQueryString = true,
queryStringKey = 'activeTab',
}) => {
const styles = useStyles2(getStyles);
const [activeTabLabel, setActiveTabLabel] = useState(defaultActiveLabel || tabs[0].label);
const defaultActiveLabel =
(shouldBeSyncedWithQueryString && LocationHelper.getQueryParam(queryStringKey)) || tabs[0].label;
const [activeTabLabel, setActiveTabLabel] = useState(defaultActiveLabel);
const setLabel = (label: string) => {
setActiveTabLabel(label);
if (shouldBeSyncedWithQueryString) {
LocationHelper.update({ [queryStringKey]: label }, 'partial');
}
};
useEffect(
() => () => {
if (shouldBeSyncedWithQueryString) {
LocationHelper.update({ [queryStringKey]: undefined }, 'partial');
}
},
[]
);
return (
<>
<TabsBar>
{tabs.map(({ label }) => (
<Tab
label={label}
key={label}
onChangeTab={() => setActiveTabLabel(label)}
active={activeTabLabel === label}
/>
<Tab label={label} key={label} onChangeTab={() => setLabel(label)} active={activeTabLabel === label} />
))}
</TabsBar>
<TabContent className={cn(styles.content, tabContentClassName)}>

View file

@ -3,5 +3,11 @@
line-height: 100%;
padding: 5px 8px;
color: white;
display: inline-block;
white-space: nowrap;
}
.size-small {
font-size: 12px;
height: 24px;
}

View file

@ -8,27 +8,33 @@ interface TagProps {
color?: string;
className?: string;
border?: string;
text?: string;
children?: any;
onClick?: (ev) => void;
forwardedRef?: React.MutableRefObject<HTMLSpanElement>;
size?: 'small' | 'medium';
}
const cx = cn.bind(styles);
export const Tag: FC<TagProps> = (props) => {
const { children, color, className, border, onClick } = props;
const { children, color, text, className, border, onClick, size = 'medium' } = props;
const style: React.CSSProperties = {};
if (color) {
style.backgroundColor = color;
}
if (text) {
style.color = text;
}
if (border) {
style.border = border;
}
return (
<span style={style} className={cx('root', className)} onClick={onClick} ref={props.forwardedRef}>
<span style={style} className={cx('root', `size-${size}`, className)} onClick={onClick} ref={props.forwardedRef}>
{children}
</span>
);

View file

@ -0,0 +1,129 @@
import React, { FC, useMemo } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { VerticalGroup, HorizontalGroup, Badge, useStyles2, Tooltip, Icon, useTheme2 } from '@grafana/ui';
import dayjs from 'dayjs';
import { SourceCode } from 'components/SourceCode/SourceCode';
import { Tabs } from 'components/Tabs/Tabs';
import { Text } from 'components/Text/Text';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import WebhookStatusCodeBadge from './WebhookStatusCodeBadge';
interface WebhookLastEventDetailsProps {
webhook: OutgoingWebhook;
}
const WebhookLastEventDetails: FC<WebhookLastEventDetailsProps> = ({ webhook }) => {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const rows = useMemo(() => getEventDetailsRows(theme, webhook), [theme, webhook]);
if (!webhook.last_response_log?.timestamp) {
return (
<Text type="primary" size="medium">
An event triggering of this webhook has not been sent yet.
</Text>
);
}
return (
<>
<div className={styles.lastEventDetailsRowsWrapper}>
<VerticalGroup spacing="md">
{rows.map(({ title, value }) => (
<HorizontalGroup key={title}>
<span className={styles.lastEventDetailsRowTitle}>{title}</span>
<span className={styles.lastEventDetailsRowValue}>{value}</span>
</HorizontalGroup>
))}
</VerticalGroup>
</div>
<Tabs
queryStringKey="lastEventDetailsActiveTab"
tabs={[
{
label: 'Event body',
content: (
<SourceCode showClipboardIconOnly prettifyJsonString noMaxHeight className={styles.sourceCode}>
{webhook.last_response_log.request_data || 'No data'}
</SourceCode>
),
},
{
label: 'Response body',
content: (
<SourceCode showClipboardIconOnly prettifyJsonString noMaxHeight className={styles.sourceCode}>
{webhook.last_response_log.content || 'No data'}
</SourceCode>
),
},
{
label: 'Request headers',
content: (
<SourceCode showClipboardIconOnly prettifyJsonString noMaxHeight className={styles.sourceCode}>
{webhook.last_response_log.request_headers || 'No data'}
</SourceCode>
),
},
]}
/>
</>
);
};
const getEventDetailsRows = (theme: GrafanaTheme2, webhook?: OutgoingWebhook) =>
webhook
? [
{
title: 'Trigger type',
value: webhook.trigger_type_name,
},
{
title: 'Time',
value: `${dayjs(webhook.last_response_log?.timestamp).format('DD MMM YYYY, HH:mm')} (${getTzOffsetString(
dayjs(webhook.last_response_log?.timestamp)
)})`,
},
{
title: 'URL',
value: (
<HorizontalGroup align="center">
<span>{webhook.url}</span>
{webhook.last_response_log?.url && webhook.url !== webhook.last_response_log?.url && (
<Tooltip content={webhook.last_response_log?.url}>
<Icon name="exclamation-triangle" color={theme.colors.error.main} />
</Tooltip>
)}
</HorizontalGroup>
),
},
{
title: 'Method',
value: <Badge color="blue" text={webhook.http_method} />,
},
{
title: 'Response code',
value: <WebhookStatusCodeBadge webhook={webhook} />,
},
]
: [];
const getStyles = () => ({
lastEventDetailsRowTitle: css({
width: '150px',
}),
lastEventDetailsRowValue: css({
fontWeight: 500,
}),
lastEventDetailsRowsWrapper: css({
marginBottom: '26px',
}),
sourceCode: css({
height: 'calc(100vh - 585px)',
}),
});
export default WebhookLastEventDetails;

View file

@ -0,0 +1,72 @@
import React from 'react';
import { css } from '@emotion/css';
import { useTheme2, useStyles2, HorizontalGroup, Button } from '@grafana/ui';
import dayjs from 'dayjs';
import { Tag } from 'components/Tag/Tag';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { OutgoingTabDrawerKey } from 'pages/integration/OutgoingTab/OutgoingTab.types';
import WebhookStatusCodeBadge from './WebhookStatusCodeBadge';
export const WebhookLastEventTimestamp = ({
webhook,
openDrawer,
}: {
webhook: OutgoingWebhook;
openDrawer: (key: OutgoingTabDrawerKey) => void;
}) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const lastEventMoment = dayjs(webhook.last_response_log?.timestamp);
const lastEventFormatted = `${lastEventMoment.format('DD MMM YYYY')}, ${lastEventMoment.format(
'HH:mm:ss'
)} (${getTzOffsetString(lastEventMoment)})`;
const isLastEventDateValid = lastEventMoment.isValid();
if (!isLastEventDateValid) {
return (
<Tag
color={theme.colors.background.secondary}
border={`1px solid ${theme.colors.border.weak}`}
text={theme.colors.text.secondary}
size="small"
>
Never
</Tag>
);
}
return (
<HorizontalGroup>
<Tag
color={theme.colors.background.secondary}
border={`1px solid ${theme.colors.border.weak}`}
text={theme.colors.text.primary}
size="small"
>
{lastEventFormatted}
</Tag>
<WebhookStatusCodeBadge webhook={webhook} />
<Button
size="sm"
icon="eye"
tooltip="Go to event details"
variant="secondary"
className={styles.eventDetailsIconButton}
onClick={() => openDrawer('webhookDetails')}
/>
</HorizontalGroup>
);
};
export const getStyles = () => ({
eventDetailsIconButton: css({
padding: '6px 10px',
}),
});

View file

@ -0,0 +1,43 @@
import React from 'react';
import { css } from '@emotion/css';
import { Badge, Button, useStyles2 } from '@grafana/ui';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
export const WebhookName = ({
webhook: { is_webhook_enabled, name },
onNameClick,
}: {
webhook: OutgoingWebhook;
onNameClick: () => void;
}) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.nameColumn}>
<Button fill="text" className={styles.webhookName} onClick={onNameClick}>
{name}
</Button>
{!is_webhook_enabled && <Badge className={styles.disabledBadge} text="Disabled" color="orange" icon="pause" />}
</div>
);
};
export const getStyles = () => ({
nameColumn: css({
display: 'flex',
alignItems: 'center',
gap: '4px',
}),
webhookName: css({
wordBreak: 'break-word',
padding: 0,
'&:hover': {
background: 'none',
},
}),
disabledBadge: css({
wordBreak: 'keep-all',
}),
});

View file

@ -0,0 +1,31 @@
import React, { FC } from 'react';
import { css } from '@emotion/css';
import { Badge, useStyles2 } from '@grafana/ui';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
interface WebhookStatusCodeBadgeProps {
webhook: OutgoingWebhook;
}
const WebhookStatusCodeBadge: FC<WebhookStatusCodeBadgeProps> = ({ webhook }) => {
const styles = useStyles2(getStyles);
return (
<Badge
color={webhook.last_response_log?.status_code?.startsWith?.('2') ? 'green' : 'orange'}
text={webhook.last_response_log?.status_code || 'No status'}
className={styles.lastEventBadge}
/>
);
};
const getStyles = () => ({
lastEventBadge: css({
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
}),
});
export default WebhookStatusCodeBadge;

View file

@ -16,7 +16,7 @@ import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { HamburgerMenu } from 'components/HamburgerMenu/HamburgerMenu';
import { HamburgerMenuIcon } from 'components/HamburgerMenuIcon/HamburgerMenuIcon';
import {
IntegrationCollapsibleTreeView,
IntegrationCollapsibleItem,
@ -444,7 +444,7 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
)}
>
{({ openMenu }) => (
<HamburgerMenu
<HamburgerMenuIcon
openMenu={openMenu}
listBorder={2}
listWidth={200}

View file

@ -7,24 +7,16 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import {
HTTP_METHOD_OPTIONS,
OutgoingWebhookPreset,
WebhookTriggerType,
WEBHOOK_TRIGGGER_TYPE_OPTIONS,
} from 'models/outgoing_webhook/outgoing_webhook.types';
import { generateAssignToTeamInputDescription } from 'utils/consts';
import { KeyValuePair } from 'utils/utils';
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
export const WebhookTriggerType = {
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'),
Acknowledged: new KeyValuePair('2', 'Acknowledged'),
Resolved: new KeyValuePair('3', 'Resolved'),
Silenced: new KeyValuePair('4', 'Silenced'),
Unsilenced: new KeyValuePair('5', 'Unsilenced'),
Unresolved: new KeyValuePair('6', 'Unresolved'),
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
AlertGroupStatusChange: new KeyValuePair('8', 'Alert Group Status Change'),
};
export function createForm({
presets = [],
grafanaTeamStore,
@ -78,44 +70,7 @@ export function createForm({
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.AlertGroupStatusChange.key,
label: WebhookTriggerType.AlertGroupStatusChange.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
],
options: WEBHOOK_TRIGGGER_TYPE_OPTIONS,
},
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
normalize: (value) => value,
@ -126,32 +81,7 @@ export function createForm({
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'PATCH',
label: 'PATCH',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
],
options: HTTP_METHOD_OPTIONS,
},
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
normalize: (value) => value,

View file

@ -10,9 +10,8 @@
margin: 4px;
}
.tabs__content {
.tabsWrapper {
padding-top: 16px;
padding-bottom: 16px;
}
.form-row {

View file

@ -55,7 +55,7 @@ interface OutgoingWebhookFormProps {
export const WebhookTabs = {
Settings: new KeyValuePair('Settings', 'Settings'),
LastRun: new KeyValuePair('LastRun', 'Last Run'),
LastRun: new KeyValuePair('LastRun', 'Last Event'),
};
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(({ setValue, getValues }) => {
@ -275,30 +275,38 @@ export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) =>
return (
// show tabbed drawer (edit/live_run)
<>
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
<Drawer
scrollableContent
title={'Outgoing webhook details'}
onClose={onHide}
closeOnMaskClick={false}
tabs={
<div className={cx('tabsWrapper')}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
</div>
}
>
<div className={cx('webhooks__drawerContent')}>
<TabsBar>
<Tab
key={WebhookTabs.Settings.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.Settings.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
}}
active={activeTab === WebhookTabs.Settings.key}
label={WebhookTabs.Settings.value}
/>
<Tab
key={WebhookTabs.LastRun.key}
onChangeTab={() => {
setActiveTab(WebhookTabs.LastRun.key);
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
}}
active={activeTab === WebhookTabs.LastRun.key}
label={WebhookTabs.LastRun.value}
/>
</TabsBar>
<WebhookTabsContent
id={id}
action={action}
@ -362,7 +370,7 @@ export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) =>
)}
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{isNewOrCopy ? 'Create' : 'Update'} Webhook
{isNewOrCopy ? 'Create' : 'Update'}
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
@ -392,7 +400,7 @@ interface WebhookTabsProps {
}
const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
({ id, action, activeTab, data, onHide, onUpdate, onDelete, formElement }) => {
({ id, action, activeTab, data, onHide, onDelete, formElement }) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
const { outgoingWebhookStore, hasFeature, grafanaTeamStore, alertReceiveChannelStore } = useStore();
const form = createForm({
@ -441,7 +449,7 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'} Webhook
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'}
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
@ -456,7 +464,7 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
)}
</>
)}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhookStatus id={id} onUpdate={onUpdate} />}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhookStatus id={id} closeDrawer={onHide} />}
</div>
);
}

View file

@ -1,14 +1,13 @@
import React from 'react';
import { Label, VerticalGroup } from '@grafana/ui';
import { HorizontalGroup, Button } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { Block } from 'components/GBlock/Block';
import { SourceCode } from 'components/SourceCode/SourceCode';
import { Text } from 'components/Text/Text';
import WebhookLastEventDetails from 'components/Webhooks/WebhookLastEventDetails';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { useStore } from 'state/useStore';
import { useCommonStyles } from 'utils/hooks';
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
@ -16,105 +15,27 @@ const cx = cn.bind(styles);
interface OutgoingWebhookStatusProps {
id: OutgoingWebhook['id'];
onUpdate: () => void;
closeDrawer: () => void;
}
function Debug(props) {
return (
<VerticalGroup spacing="none">
<Label>{props.title}</Label>
<Block bordered fullWidth>
<VerticalGroup spacing="none">
{props.source && <SourceCode showClipboardIconOnly>{props.source}</SourceCode>}
{props.result && props.result !== props.source && (
<VerticalGroup spacing="none">
<Label>Result</Label>
<SourceCode showClipboardIconOnly>{props.result}</SourceCode>
</VerticalGroup>
)}
</VerticalGroup>
</Block>
</VerticalGroup>
);
}
function format_response_field(str) {
try {
const jsonValue = JSON.parse(str);
return JSON.stringify(jsonValue, null, 4);
} catch (e) {
return str;
}
}
export const OutgoingWebhookStatus = observer((props: OutgoingWebhookStatusProps) => {
const { id } = props;
const store = useStore();
const { outgoingWebhookStore } = store;
const data = outgoingWebhookStore.items[id];
export const OutgoingWebhookStatus = observer(({ id, closeDrawer }: OutgoingWebhookStatusProps) => {
const {
outgoingWebhookStore: {
items: { [id]: webhook },
},
} = useStore();
const commonStyles = useCommonStyles();
return (
<div className={cx('content')}>
<VerticalGroup>
<Label>Webhook Name</Label>
<SourceCode showClipboardIconOnly>{data.name}</SourceCode>
<Label>Webhook ID</Label>
<SourceCode showClipboardIconOnly>{data.id}</SourceCode>
<Label>Trigger Type</Label>
<SourceCode showClipboardIconOnly>{data.trigger_type_name}</SourceCode>
{data.last_response_log.timestamp ? (
<VerticalGroup>
<Label>Last Run Time</Label>
<SourceCode showClipboardIconOnly>{data.last_response_log.timestamp}</SourceCode>
{data.last_response_log.url && (
<Debug title="URL" source={data.url} result={data.last_response_log.url}></Debug>
)}
{data.last_response_log.status_code && (
<VerticalGroup>
<Label>Response Code</Label>
<SourceCode showClipboardIconOnly>{data.last_response_log.status_code}</SourceCode>
</VerticalGroup>
)}
{data.last_response_log.content && (
<VerticalGroup>
<Label>Response Body</Label>
<SourceCode showClipboardIconOnly>{format_response_field(data.last_response_log.content)}</SourceCode>
</VerticalGroup>
)}
{data.last_response_log.request_trigger && (
<Debug
title="Trigger Template"
source={data.trigger_template}
result={data.last_response_log.request_trigger}
></Debug>
)}
{data.last_response_log.request_headers && (
<Debug
title="Request Headers"
source={data.headers}
result={data.last_response_log.request_headers}
></Debug>
)}
{data.last_response_log.request_data && (
<Debug
title="Request Data"
source={data.data}
result={format_response_field(data.last_response_log.request_data)}
></Debug>
)}
</VerticalGroup>
) : (
<Text type="primary" size="medium">
An event triggering this webhook has not been sent yet!
</Text>
)}
</VerticalGroup>
<WebhookLastEventDetails webhook={webhook} />
<div className={commonStyles.bottomDrawerButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={closeDrawer}>
Close
</Button>
</HorizontalGroup>
</div>
</div>
);
});

View file

@ -20,8 +20,8 @@ const cx = cn.bind(styles);
interface Template {
value: string;
displayName: string;
description: string;
name: undefined;
description?: string;
name: string;
}
interface WebhooksTemplateEditorProps {
@ -37,9 +37,9 @@ export const WebhooksTemplateEditor: React.FC<WebhooksTemplateEditorProps> = ({
onHide,
handleSubmit,
}) => {
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState<boolean>(false);
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(template.value);
const [selectedPayload, setSelectedPayload] = useState(undefined);
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState(false);
const [changedTemplateBody, setChangedTemplateBody] = useState(template.value);
const [selectedPayload, setSelectedPayload] = useState();
const [resultError, setResultError] = useState<string>(undefined);
const getChangeHandler = () => {

View file

@ -94,11 +94,11 @@ export class AlertReceiveChannelStore {
}
async fetchItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
const {
data: { results },
} = await onCallApi().GET('/alert_receive_channels/', { params });
} = await onCallApi().GET('/alert_receive_channels/', {
params: { query: typeof query === 'string' ? { search: query } : query },
});
runInAction(() => {
this.items = {

View file

@ -1,5 +1,6 @@
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { LabelKeyValue } from 'models/label/label.types';
import { KeyValuePair } from 'utils/utils';
export interface OutgoingWebhook {
authorization_header: string;
@ -41,3 +42,81 @@ export interface OutgoingWebhookPreset {
logo: string;
controlled_fields: string[];
}
export const WebhookTriggerType = {
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'),
Acknowledged: new KeyValuePair('2', 'Acknowledged'),
Resolved: new KeyValuePair('3', 'Resolved'),
Silenced: new KeyValuePair('4', 'Silenced'),
Unsilenced: new KeyValuePair('5', 'Unsilenced'),
Unresolved: new KeyValuePair('6', 'Unresolved'),
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
AlertGroupStatusChange: new KeyValuePair('8', 'Alert Group Status Change'),
};
export const WEBHOOK_TRIGGGER_TYPE_OPTIONS = [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.AlertGroupStatusChange.key,
label: WebhookTriggerType.AlertGroupStatusChange.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
];
export const HTTP_METHOD_OPTIONS = [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'PATCH',
label: 'PATCH',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
];

View file

@ -126,9 +126,6 @@ $LARGE-MARGIN: 24px;
align-items: center;
gap: 8px;
}
.tag {
height: 25px;
}
.heartbeat-badge {
padding: 4px 8px;
@ -208,10 +205,6 @@ $LARGE-MARGIN: 24px;
}
}
.radius {
border-radius: 4px;
}
.inline-switch {
height: 34px;
border: var(--border-weak);

View file

@ -22,25 +22,23 @@ import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
import { getTemplatesForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import { HamburgerMenu } from 'components/HamburgerMenu/HamburgerMenu';
import { HamburgerContextMenu } from 'components/HamburgerContextMenu/HamburgerContextMenu';
import {
IntegrationCollapsibleTreeView,
IntegrationCollapsibleItem,
} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
import { IntegrationContactPoint } from 'components/IntegrationContactPoint/IntegrationContactPoint';
import { IntegrationHowToConnect } from 'components/IntegrationHowToConnect/IntegrationHowToConnect';
import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo';
import IntegrationLogoWithTitle from 'components/IntegrationLogo/IntegrationLogoWithTitle';
import { IntegrationSendDemoAlertModal } from 'components/IntegrationSendDemoAlertModal/IntegrationSendDemoAlertModal';
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { IntegrationTag } from 'components/Integrations/IntegrationTag';
import { PageErrorHandlingWrapper, PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import { initErrorDataState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import { PluginLink } from 'components/PluginLink/PluginLink';
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
import { Tabs } from 'components/Tabs/Tabs';
import { Tag } from 'components/Tag/Tag';
import { Text } from 'components/Text/Text';
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import { EditRegexpRouteTemplateModal } from 'containers/EditRegexpRouteTemplateModal/EditRegexpRouteTemplateModal';
import { CollapsedIntegrationRouteDisplay } from 'containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay';
import { ExpandedIntegrationRouteDisplay } from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay';
@ -65,7 +63,6 @@ import { AppFeature } from 'state/features';
import { PageProps, SelectOption, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { getVar } from 'utils/DOM';
import { LocationHelper } from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
@ -73,6 +70,8 @@ import { getItem, setItem } from 'utils/localStorage';
import { sanitize } from 'utils/sanitize';
import { openNotification, openErrorNotification } from 'utils/utils';
import { OutgoingTab } from './OutgoingTab/OutgoingTab';
const cx = cn.bind(styles);
interface IntegrationProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -261,7 +260,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
<Tabs
tabs={[
{ label: 'Incoming', content: incomingPart },
{ label: 'Outgoing', content: <div>outgoing tab content</div> },
{ label: 'Outgoing', content: <OutgoingTab /> },
]}
/>
) : (
@ -467,15 +466,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
noContent
heading={
<div className={cx('templates__outer-container')}>
<Tag
color={getVar('--tag-secondary-transparent')}
border={getVar('--border-weak')}
className={cx('tag')}
>
<Text type="primary" size="small" className={cx('radius')}>
Templates
</Text>
</Tag>
<IntegrationTag>Templates</IntegrationTag>
<div className={cx('templates__content')}>
<div className={cx('templates__container')}>
@ -925,165 +916,132 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</WithPermissionControlTooltip>
<div data-testid="integration-settings-context-menu-wrapper">
<WithContextMenu
renderMenuItems={() => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
<Text type="primary">Integration Settings</Text>
</div>
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => openLabelsForm()}>
<Text type="primary">Alert group labeling</Text>
</div>
</WithPermissionControlTooltip>
)}
{showHeartbeatSettings() && (
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => setIsHeartbeatFormOpen(true)}
data-testid="integration-heartbeat-settings"
>
Heartbeat Settings
</div>
</WithPermissionControlTooltip>
)}
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={openStartMaintenance}
data-testid="integration-start-maintenance"
>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')} onClick={changeIsTemplateSettingsOpen}>
<Text type="primary">Edit Templates</Text>
</div>
</WithPermissionControlTooltip>
{alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => {
setConfirmModal({
isOpen: true,
confirmText: 'Stop',
dismissText: 'Cancel',
onConfirm: onStopMaintenance,
title: 'Stop Maintenance',
body: (
<Text type="primary">
Are you sure you want to stop the maintenance for{' '}
<Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
});
}}
data-testid="integration-stop-maintenance"
>
<Text type="primary">Stop Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
{isLegacyIntegration && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() =>
setConfirmModal({
isOpen: true,
title: 'Migrate Integration?',
body: (
<VerticalGroup spacing="lg">
<Text type="primary">
Are you sure you want to migrate <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
<VerticalGroup spacing="xs">
<Text type="secondary">- Integration internal behaviour will be changed</Text>
<Text type="secondary">
- Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '}
configuration
</Text>
<Text type="secondary">
- Integration templates will be reset to suit the new payload
</Text>
<Text type="secondary">
- It is needed to adjust routes manually to the new payload
</Text>
</VerticalGroup>
</VerticalGroup>
),
onConfirm: onIntegrationMigrate,
dismissText: 'Cancel',
confirmText: 'Migrate',
})
}
>
Migrate
</div>
</WithPermissionControlTooltip>
)}
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
>
<div className={cx('integration__actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {alertReceiveChannel.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<RenderConditionally shouldRender={alertReceiveChannel.allow_delete}>
<div className={cx('thin-line-break')} />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')}>
<div
onClick={() => {
setConfirmModal({
isOpen: true,
title: 'Delete Integration?',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
onConfirm: deleteIntegration,
dismissText: 'Cancel',
confirmText: 'Delete',
});
}}
className="u-width-100"
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
<HamburgerContextMenu
items={[
{
onClick: openIntegrationSettings,
label: 'Integration Settings',
},
{
label: 'ServiceNow configuration',
hidden: !getIsBidirectionalIntegration(alertReceiveChannel),
},
{
onClick: openLabelsForm,
hidden: !store.hasFeature(AppFeature.Labels),
label: 'Alert group labeling',
requiredPermission: UserActions.IntegrationsWrite,
},
{
onClick: () => setIsHeartbeatFormOpen(true),
hidden: !showHeartbeatSettings(),
label: <div data-testid="integration-heartbeat-settings">Heartbeat Settings</div>,
requiredPermission: UserActions.IntegrationsWrite,
},
{
onClick: openStartMaintenance,
hidden: Boolean(alertReceiveChannel.maintenance_till),
label: 'Start Maintenance',
requiredPermission: UserActions.MaintenanceWrite,
},
{
onClick: changeIsTemplateSettingsOpen,
label: 'Edit Templates',
requiredPermission: UserActions.MaintenanceWrite,
},
{
onClick: () => {
setConfirmModal({
isOpen: true,
confirmText: 'Stop',
dismissText: 'Cancel',
onConfirm: onStopMaintenance,
title: 'Stop Maintenance',
body: (
<Text type="primary">
Are you sure you want to stop the maintenance for{' '}
<Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
});
},
hidden: !alertReceiveChannel.maintenance_till,
label: 'Stop Maintenance',
requiredPermission: UserActions.MaintenanceWrite,
},
{
onClick: () =>
setConfirmModal({
isOpen: true,
title: 'Migrate Integration?',
body: (
<VerticalGroup spacing="lg">
<Text type="primary">
Are you sure you want to migrate <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
</div>
<VerticalGroup spacing="xs">
<Text type="secondary">- Integration internal behaviour will be changed</Text>
<Text type="secondary">
- Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '}
configuration
</Text>
<Text type="secondary">- Integration templates will be reset to suit the new payload</Text>
<Text type="secondary">- It is needed to adjust routes manually to the new payload</Text>
</VerticalGroup>
</VerticalGroup>
),
onConfirm: onIntegrationMigrate,
dismissText: 'Cancel',
confirmText: 'Migrate',
}),
hidden: !isLegacyIntegration,
label: 'Migrate',
requiredPermission: UserActions.IntegrationsWrite,
},
{
label: (
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
>
<div>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {alertReceiveChannel.id}</Text>
</HorizontalGroup>
</div>
</WithPermissionControlTooltip>
</RenderConditionally>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} withBackground />}
</WithContextMenu>
</CopyToClipboard>
),
},
{
onClick: () => {
setConfirmModal({
isOpen: true,
title: 'Delete Integration?',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
onConfirm: deleteIntegration,
dismissText: 'Cancel',
confirmText: 'Delete',
});
},
hidden: !alertReceiveChannel.allow_delete,
label: (
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
),
requiredPermission: UserActions.IntegrationsWrite,
},
]}
/>
</div>
</div>
</>
@ -1231,10 +1189,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px', marginLeft: '8px' }}>
<div className={cx('headerTop__item')}>
<Text type="secondary">Type:</Text>
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="primary">{integration?.display_name}</Text>
</HorizontalGroup>
<IntegrationLogoWithTitle integration={integration} />
</div>
<div className={cx('headerTop__item')}>
<Text type="secondary">Team:</Text>

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { Text } from 'components/Text/Text';
import ConnectedIntegrationsTable from './ConnectedIntegrationsTable';
import { getStyles } from './OutgoingTab.styles';
export const ConnectIntegrationModal = ({ onDismiss }: { onDismiss: () => void }) => {
const styles = useStyles2(getStyles);
return (
<Modal
isOpen
title={<Text.Title level={4}>Connect integration</Text.Title>}
closeOnBackdropClick={false}
closeOnEscape
onDismiss={onDismiss}
>
<Input
className={styles.searchIntegrationsInput}
suffix={<Icon name="search" />}
placeholder="Search integrations..."
/>
<ConnectedIntegrationsTable />
</Modal>
);
};

View file

@ -0,0 +1,73 @@
import React, { FC } from 'react';
import { HorizontalGroup, Tooltip, Icon, useStyles2, IconButton, Switch } from '@grafana/ui';
import { GTable } from 'components/GTable/GTable';
import IntegrationLogoWithTitle from 'components/IntegrationLogo/IntegrationLogoWithTitle';
import { Text } from 'components/Text/Text';
import { getStyles } from './OutgoingTab.styles';
interface ConnectedIntegrationsTableProps {
allowDelete?: boolean;
}
const ConnectedIntegrationsTable: FC<ConnectedIntegrationsTableProps> = (props) => {
const FAKE_INTEGRATIONS = [{ a: 'a' }];
return (
<GTable
emptyText={FAKE_INTEGRATIONS ? 'No integrations found' : 'Loading...'}
rowKey="id"
columns={getColumns(props)}
data={FAKE_INTEGRATIONS}
/>
);
};
const getColumns = ({ allowDelete }: ConnectedIntegrationsTableProps) => [
{
width: '45%',
title: <Text type="secondary">Integration name</Text>,
dataIndex: 'trigger_type_name',
render: () => <>Some integration name</>,
},
{
width: '55%',
title: <Text type="secondary">Type</Text>,
render: () => <IntegrationLogoWithTitle integration={{ value: 'elastalert', display_name: 'ElastAlerts' }} />,
},
{
title: (
<HorizontalGroup>
<Text type="secondary">Backsync</Text>
<Tooltip content={<>Switch on to start sending data from other integrations</>}>
<Icon name={'info-circle'} />
</Tooltip>
</HorizontalGroup>
),
render: BacksyncSwitcher,
},
{
render: () => <ActionsColumn allowDelete={allowDelete} />,
},
];
const BacksyncSwitcher = () => {
const styles = useStyles2(getStyles);
return (
<div className={styles.backsyncColumn}>
<Switch defaultChecked />
</div>
);
};
const ActionsColumn = ({ allowDelete }: { allowDelete?: boolean }) => (
<HorizontalGroup>
<IconButton aria-label="Open integration in new tab" name="external-link-alt" />
{allowDelete && <IconButton aria-label="Remove backsync" name="trash-alt" />}
</HorizontalGroup>
);
export default ConnectedIntegrationsTable;

View file

@ -0,0 +1,46 @@
import React, { FC } from 'react';
import { Button, HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
import { useForm, FormProvider } from 'react-hook-form';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { UserActions } from 'utils/authorization/authorization';
import { useCommonStyles } from 'utils/hooks';
import { getStyles } from './OutgoingTab.styles';
import { OutgoingTabFormValues } from './OutgoingTab.types';
import { OutgoingWebhookFormFields } from './OutgoingWebhookFormFields';
interface NewOutgoingWebhookDrawerContentProps {
closeDrawer: () => void;
}
export const NewOutgoingWebhookDrawerContent: FC<NewOutgoingWebhookDrawerContentProps> = ({ closeDrawer }) => {
const styles = useStyles2(getStyles);
const commonStyles = useCommonStyles();
const formMethods = useForm<OutgoingTabFormValues>({ mode: 'all' });
const onSubmit = () => {};
return (
<FormProvider {...formMethods}>
<form onSubmit={formMethods.handleSubmit(onSubmit)} className={styles.form}>
<VerticalGroup justify="space-between">
<div className={styles.formFieldsWrapper}>
<OutgoingWebhookFormFields webhookId="new" />
</div>
<div className={commonStyles.bottomDrawerButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={closeDrawer}>
Close
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button type="submit">Create</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</VerticalGroup>
</form>
</FormProvider>
);
};

View file

@ -0,0 +1,31 @@
import React, { useState } from 'react';
import { Button, HorizontalGroup } from '@grafana/ui';
import { observer } from 'mobx-react-lite';
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import IntegrationTag from 'components/Integrations/IntegrationTag';
import { ConnectIntegrationModal } from './ConnectIntegrationModal';
import ConnectedIntegrationsTable from './ConnectedIntegrationsTable';
export const OtherIntegrations = observer(() => {
const [isConnectModalOpened, setIsConnectModalOpened] = useState(false);
return (
<>
{isConnectModalOpened && <ConnectIntegrationModal onDismiss={() => setIsConnectModalOpened(false)} />}
<IntegrationBlock
heading={
<HorizontalGroup justify="space-between">
<IntegrationTag>Send data from other integrations</IntegrationTag>
<Button size="sm" variant="secondary" onClick={() => setIsConnectModalOpened(true)}>
Connect
</Button>
</HorizontalGroup>
}
content={<ConnectedIntegrationsTable allowDelete />}
/>
</>
);
});

View file

@ -0,0 +1,77 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getStyles = (theme: GrafanaTheme2) => ({
urlIntegrationBlock: css({
marginBottom: '32px',
}),
urlInput: css({
height: '25px',
background: theme.colors.background.canvas,
'& input': {
height: '25px',
},
}),
form: css({
height: '100%',
paddingBottom: '64px',
marginTop: '32px',
}),
formFieldsWrapper: css({
width: '100%',
}),
infoIcon: css({
marginLeft: '4px',
}),
monacoEditorLabelWrapper: css({
display: 'flex',
width: '100%',
}),
switcherFieldWrapper: css({
display: 'flex',
gap: '8px',
alignItems: 'center',
}),
switcherLabel: css({
marginBottom: 0,
}),
selectField: css({
width: '200px',
marginBottom: 0,
}),
hamburgerIcon: css({
background: theme.colors.secondary.shade,
}),
horizontalGroup: css({
display: 'flex',
gap: '8px',
}),
openConfigurationBtn: css({
background: theme.colors.secondary.shade,
}),
outgoingWebhooksTable: css({
margin: '24px 0',
}),
backsyncColumn: css({
display: 'flex',
justifyContent: 'flex-end',
}),
triggerTemplateWrapper: css({
position: 'relative',
width: '100%',
}),
addTriggerTemplate: css({
marginBottom: '16px',
}),
editTriggerTemplateBtn: css({
position: 'absolute',
top: '-8px',
right: 0,
}),
searchIntegrationsInput: css({
marginBottom: '24px',
}),
tabsWrapper: css({
padding: '16px 16px 16px 8px',
}),
});

View file

@ -0,0 +1,114 @@
import React from 'react';
import { useStyles2, Input, IconButton, Button, Drawer, Badge } from '@grafana/ui';
import CopyToClipboardIcon from 'components/CopyToClipboardIcon/CopyToClipboardIcon';
import { IntegrationCollapsibleTreeView } from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { IntegrationTag } from 'components/Integrations/IntegrationTag';
import { useDrawer } from 'utils/hooks';
import { NewOutgoingWebhookDrawerContent } from './NewOutgoingWebhookDrawerContent';
import { OtherIntegrations } from './OtherIntegrations';
import { getStyles } from './OutgoingTab.styles';
import { OutgoingTabDrawerKey } from './OutgoingTab.types';
import { OutgoingWebhookDetailsDrawerTabs } from './OutgoingWebhookDetailsDrawerTabs';
import { OutgoingWebhooksTable } from './OutgoingWebhooksTable';
export const OutgoingTab = () => {
const { openDrawer, closeDrawer, getIsDrawerOpened } = useDrawer<OutgoingTabDrawerKey>();
return (
<>
{getIsDrawerOpened('webhookDetails') && (
<Drawer
title="Outgoing webhook details"
onClose={closeDrawer}
width="640px"
tabs={<OutgoingWebhookDetailsDrawerTabs closeDrawer={closeDrawer} />}
>
<div />
</Drawer>
)}
{getIsDrawerOpened('newOutgoingWebhook') && (
<Drawer title="New Outgoing Webhook" onClose={closeDrawer} width="640px">
<NewOutgoingWebhookDrawerContent closeDrawer={closeDrawer} />
</Drawer>
)}
<IntegrationCollapsibleTreeView
configElements={[
{
customIcon: 'plug',
startingElemPosition: '50%',
expandedView: () => <Connection />,
},
{
customIcon: 'plus',
expandedView: () => (
<>
<AddOutgoingWebhook openDrawer={openDrawer} />
<OutgoingWebhooksTable openDrawer={openDrawer} />
</>
),
},
{
customIcon: 'exchange-alt',
startingElemPosition: '50%',
expandedView: () => <OtherIntegrations />,
},
]}
/>
</>
);
};
const Connection = () => {
const styles = useStyles2(getStyles);
const FAKE_URL = 'https://example.com';
const value = FAKE_URL;
return (
<div>
<IntegrationBlock
noContent
className={styles.urlIntegrationBlock}
heading={
<div className={styles.horizontalGroup}>
<IntegrationTag>ServiceNow connection</IntegrationTag>
<Badge text="OK" color="green" />
<Input
value={value}
disabled
className={styles.urlInput}
suffix={
<>
<CopyToClipboardIcon text={FAKE_URL} />
<IconButton
aria-label="Open in new tab"
name="external-link-alt"
onClick={() => window.open(value, '_blank')}
/>
</>
}
/>
<Button
size="sm"
icon="cog"
tooltip="Open ServiceNow configuration"
variant="secondary"
name="cog"
aria-label="Open ServiceNow configuration"
className={styles.openConfigurationBtn}
/>
</div>
}
/>
<h4>Outgoing webhooks</h4>
</div>
);
};
const AddOutgoingWebhook = ({ openDrawer }: { openDrawer: (key: OutgoingTabDrawerKey) => void }) => (
<Button onClick={() => openDrawer('newOutgoingWebhook')}>Add webhook</Button>
);

View file

@ -0,0 +1,23 @@
export type OutgoingTabDrawerKey = 'webhookDetails' | 'newOutgoingWebhook';
export const TriggerDetailsQueryStringKey = {
ActiveTab: 'activeEventTriggerDrawerTab',
WebhookId: 'webhookId',
};
export const TriggerDetailsTab = {
Settings: 'Settings',
LastEvent: 'Last event',
} as const;
export type TriggerDetailsTab = (typeof TriggerDetailsTab)[keyof typeof TriggerDetailsTab];
export interface OutgoingTabFormValues {
triggerType: string;
isEnabled?: boolean;
url: string;
httpMethod: string;
triggerTemplateToogle?: boolean;
triggerTemplate?: string;
forwardedDataTemplateToogle?: boolean;
forwardedDataTemplate?: string;
}

View file

@ -0,0 +1,111 @@
import React, { FC } from 'react';
import { Button, HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
import { observer } from 'mobx-react-lite';
import { useForm, FormProvider } from 'react-hook-form';
import { Tabs } from 'components/Tabs/Tabs';
import WebhookLastEventDetails from 'components/Webhooks/WebhookLastEventDetails';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { useStore } from 'state/useStore';
import { LocationHelper } from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization/authorization';
import { useCommonStyles } from 'utils/hooks';
import { getStyles } from './OutgoingTab.styles';
import { OutgoingTabFormValues, TriggerDetailsQueryStringKey, TriggerDetailsTab } from './OutgoingTab.types';
import { OutgoingWebhookFormFields } from './OutgoingWebhookFormFields';
interface OutgoingWebhookDetailsDrawerTabsProps {
closeDrawer: () => void;
}
export const OutgoingWebhookDetailsDrawerTabs: FC<OutgoingWebhookDetailsDrawerTabsProps> = ({ closeDrawer }) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.tabsWrapper}>
<Tabs
queryStringKey={TriggerDetailsQueryStringKey.ActiveTab}
tabs={[
{ label: TriggerDetailsTab.Settings, content: <Settings closeDrawer={closeDrawer} /> },
{ label: TriggerDetailsTab.LastEvent, content: <LastEventDetails closeDrawer={closeDrawer} /> },
]}
/>
</div>
);
};
interface SettingsProps {
closeDrawer: () => void;
}
const Settings: FC<SettingsProps> = ({ closeDrawer }) => {
const styles = useStyles2(getStyles);
const commonStyles = useCommonStyles();
const form = useForm<OutgoingTabFormValues>({ mode: 'all' });
const webhookId = LocationHelper.getQueryParam(TriggerDetailsQueryStringKey.WebhookId);
const onSubmit = () => {};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
<VerticalGroup justify="space-between">
<div className={styles.formFieldsWrapper}>
<OutgoingWebhookFormFields webhookId={webhookId} />
</div>
<div className={commonStyles.bottomDrawerButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={closeDrawer}>
Close
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button
type="submit"
onClick={() => {
form.handleSubmit(onSubmit);
}}
>
Update
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button type="submit" variant="destructive" fill="outline">
Delete
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</VerticalGroup>
</form>
</FormProvider>
);
};
interface LastEventDetailsProps {
closeDrawer: () => void;
}
const LastEventDetails: FC<LastEventDetailsProps> = observer(({ closeDrawer }) => {
const commonStyles = useCommonStyles();
const {
outgoingWebhookStore: { items },
} = useStore();
const webhook = items[LocationHelper.getQueryParam(TriggerDetailsQueryStringKey.WebhookId)];
if (!webhook) {
return null;
}
return (
<div>
<WebhookLastEventDetails webhook={webhook} />
<div className={commonStyles.bottomDrawerButtons}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={closeDrawer}>
Close
</Button>
</HorizontalGroup>
</div>
</div>
);
});

View file

@ -0,0 +1,230 @@
import React, { FC, useState } from 'react';
import {
Button,
Field,
HorizontalGroup,
Icon,
Input,
Label,
Select,
Switch,
Tooltip,
useStyles2,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames';
import { Controller, useFormContext } from 'react-hook-form';
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { WebhooksTemplateEditor } from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { HTTP_METHOD_OPTIONS, WEBHOOK_TRIGGGER_TYPE_OPTIONS } from 'models/outgoing_webhook/outgoing_webhook.types';
import { getStyles } from './OutgoingTab.styles';
import { OutgoingTabFormValues } from './OutgoingTab.types';
interface TemplateToEdit {
value: string;
displayName: string;
name: string;
}
interface OutgoingWebhookFormFieldsProps {
// "new" should be used for new webhook
webhookId: string;
}
export const OutgoingWebhookFormFields: FC<OutgoingWebhookFormFieldsProps> = ({ webhookId }) => {
const styles = useStyles2(getStyles);
const { control, watch, formState, register } = useFormContext<OutgoingTabFormValues>();
const [templateToEdit, setTemplateToEdit] = useState<TemplateToEdit>();
const [showTriggerTemplate] = watch(['triggerTemplateToogle', 'forwardedDataTemplateToogle']);
return (
<VerticalGroup spacing="lg">
<div className={styles.switcherFieldWrapper}>
<Controller
control={control}
name="isEnabled"
render={({ field: { value, onChange } }) => <Switch value={value} onChange={() => onChange(!value)} />}
/>
<Label className={styles.switcherLabel}>Enabled</Label>
</div>
<Controller
control={control}
name="triggerType"
rules={{
required: 'Trigger type is required',
}}
render={({ field }) => (
<Field
key="triggerType"
invalid={Boolean(formState.errors.triggerType)}
error={formState.errors.triggerType?.message}
label={
<Label>
<span>Trigger type</span>&nbsp;
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
}
className={styles.selectField}
>
<Select
{...field}
onChange={({ value }) => field.onChange(value)}
menuShouldPortal
options={WEBHOOK_TRIGGGER_TYPE_OPTIONS}
placeholder="Select event"
/>
</Field>
)}
/>
<Field
key="url"
invalid={Boolean(formState.errors.url)}
error={formState.errors.url?.message}
label={
<Label>
<span>Webhook URL</span>&nbsp;
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
}
className={styles.selectField}
>
<Input {...register('url', { required: 'URL is required' })} />
</Field>
<Controller
control={control}
name="httpMethod"
rules={{
required: 'HTTP method is required',
}}
render={({ field }) => (
<Field
key="httpMethod"
invalid={Boolean(formState.errors.httpMethod)}
error={formState.errors.httpMethod?.message}
label={
<Label>
<span>HTTP method</span>&nbsp;
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
}
className={styles.selectField}
>
<Select
{...field}
onChange={({ value }) => field.onChange(value)}
menuShouldPortal
options={HTTP_METHOD_OPTIONS}
placeholder="Select HTTP method"
/>
</Field>
)}
/>
<Controller
control={control}
name="forwardedDataTemplate"
render={({ field }) => (
<VerticalGroup>
<HorizontalGroup width="100%" justify="space-between">
<Label className={styles.switcherLabel}>Data template</Label>
<Button
icon="edit"
variant="secondary"
onClick={() => {
setTemplateToEdit({
value: field.value,
displayName: 'forwarded data',
name: field.name,
});
}}
/>
</HorizontalGroup>
<MonacoEditor
{...field}
data={{}} // TODO:update
showLineNumbers={false}
monacoOptions={MONACO_READONLY_CONFIG}
onChange={field.onChange}
/>
{templateToEdit?.['name'] === field.name && (
<WebhooksTemplateEditor
id={webhookId}
handleSubmit={(value) => {
field.onChange(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</VerticalGroup>
)}
/>
<div className={styles.triggerTemplateWrapper}>
<div className={cn(styles.switcherFieldWrapper, styles.addTriggerTemplate)}>
<Controller
control={control}
name="triggerTemplateToogle"
render={({ field: { value, onChange } }) => <Switch value={value} onChange={() => onChange(!value)} />}
/>
<Label className={styles.switcherLabel}>
<span>Add trigger template</span>
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
</div>
{showTriggerTemplate && (
<Controller
control={control}
name="triggerTemplate"
render={({ field }) => (
<>
<MonacoEditor
{...field}
data={{}}
showLineNumbers={false}
monacoOptions={MONACO_READONLY_CONFIG}
onChange={field.onChange}
/>
<Button
icon="edit"
variant="secondary"
className={styles.editTriggerTemplateBtn}
onClick={() => {
setTemplateToEdit({
value: field.value,
displayName: 'outgoing webhook',
name: field.name,
});
}}
/>
{templateToEdit?.['name'] === field.name && (
<WebhooksTemplateEditor
id={webhookId}
handleSubmit={(value) => {
field.onChange(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
)}
/>
)}
</div>
</VerticalGroup>
);
};

View file

@ -0,0 +1,153 @@
import React, { useEffect } from 'react';
import { IconButton, HorizontalGroup, Icon, ConfirmModal, useStyles2 } from '@grafana/ui';
import { observer } from 'mobx-react-lite';
import CopyToClipboard from 'react-copy-to-clipboard';
import { GTable } from 'components/GTable/GTable';
import { HamburgerContextMenu } from 'components/HamburgerContextMenu/HamburgerContextMenu';
import { Text } from 'components/Text/Text';
import { WebhookLastEventTimestamp } from 'components/Webhooks/WebhookLastEventTimestamp';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { useStore } from 'state/useStore';
import { LocationHelper } from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization/authorization';
import { useConfirmModal } from 'utils/hooks';
import { openNotification } from 'utils/utils';
import { getStyles } from './OutgoingTab.styles';
import { OutgoingTabDrawerKey, TriggerDetailsQueryStringKey, TriggerDetailsTab } from './OutgoingTab.types';
export const OutgoingWebhooksTable = observer(({ openDrawer }: { openDrawer: (key: OutgoingTabDrawerKey) => void }) => {
const styles = useStyles2(getStyles);
const {
outgoingWebhookStore: { getSearchResult, updateItems },
} = useStore();
useEffect(() => {
updateItems();
}, []);
const openTriggerDetailsDrawer = (tab: TriggerDetailsTab, webhookId: string) => {
LocationHelper.update(
{ [TriggerDetailsQueryStringKey.ActiveTab]: tab, [TriggerDetailsQueryStringKey.WebhookId]: webhookId },
'partial'
);
openDrawer('webhookDetails');
};
const webhooks = getSearchResult();
return (
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
rowKey="id"
columns={getColumns(openTriggerDetailsDrawer)}
data={webhooks}
className={styles.outgoingWebhooksTable}
/>
);
});
const getColumns = (openTriggerDetailsDrawer: (tab: TriggerDetailsTab, webhookId: string) => void) => [
{
width: '35%',
title: <Text type="secondary">Trigger type</Text>,
dataIndex: 'trigger_type_name',
render: (triggerType: string) => <>{triggerType}</>,
},
{
width: '65%',
title: <Text type="secondary">Last event</Text>,
render: (webhook: OutgoingWebhook) => (
<WebhookLastEventTimestamp
webhook={webhook}
openDrawer={() => openTriggerDetailsDrawer(TriggerDetailsTab.LastEvent, webhook.id)}
/>
),
},
{
key: 'action',
render: (webhook: OutgoingWebhook) => (
<OutgoingWebhookContextMenu webhook={webhook} openDrawer={openTriggerDetailsDrawer} />
),
},
];
const OutgoingWebhookContextMenu = ({
webhook,
openDrawer,
}: {
webhook: OutgoingWebhook;
openDrawer: (tab: TriggerDetailsTab, webhookId: string) => void;
}) => {
const { modalProps, openModal } = useConfirmModal();
return (
<>
<ConfirmModal {...modalProps} />
<HamburgerContextMenu
items={[
{
onClick: () => {
openDrawer(TriggerDetailsTab.Settings, webhook.id);
},
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: <Text type="primary">Webhook settings</Text>,
},
{
onClick: () => {
openDrawer(TriggerDetailsTab.LastEvent, webhook.id);
},
requiredPermission: UserActions.OutgoingWebhooksRead,
label: <Text type="primary">View Last Event</Text>,
},
{
onClick: () => {
openModal({
onConfirm: () => {},
title: `Are you sure you want to ${
webhook.is_webhook_enabled ? 'disable' : 'enable'
} outgoing webhook?`,
});
},
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: <Text type="primary">{webhook.is_webhook_enabled ? 'Disable' : 'Enable'}</Text>,
},
{
label: (
<CopyToClipboard
key="uid"
text={webhook.id}
onCopy={() => openNotification('Webhook ID has been copied')}
>
<div>
<HorizontalGroup type="primary" spacing="xs">
<Icon name="clipboard-alt" />
<Text type="primary">UID: {webhook.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
),
},
'divider',
{
onClick: () => {
openModal({
onConfirm: () => {},
title: `Are you sure you want to delete outgoing webhook?`,
});
},
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: (
<HorizontalGroup spacing="xs">
<IconButton tooltip="Remove" tooltipPlacement="top" variant="destructive" name="trash-alt" />
<Text type="danger">Delete webhook</Text>
</HorizontalGroup>
),
},
]}
/>
</>
);
};

View file

@ -20,7 +20,7 @@ import Emoji from 'react-emoji-render';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { GTable } from 'components/GTable/GTable';
import { HamburgerMenu } from 'components/HamburgerMenu/HamburgerMenu';
import { HamburgerMenuIcon } from 'components/HamburgerMenuIcon/HamburgerMenuIcon';
import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo';
import { LabelsTooltipBadge } from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
@ -546,7 +546,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} />}
{({ openMenu }) => <HamburgerMenuIcon openMenu={openMenu} listBorder={2} listWidth={200} />}
</WithContextMenu>
);
};

View file

@ -10,28 +10,8 @@
align-items: baseline;
}
.hamburgerMenu {
display: flex;
flex-direction: column;
width: 225px;
border-radius: 2px;
}
.hamburgerMenu__item {
padding: 8px;
display: flex;
align-items: center;
flex-direction: row;
flex-shrink: 0;
white-space: nowrap;
border-left: 2px solid transparent;
cursor: pointer;
min-width: 84px;
display: flex;
gap: 8px;
flex-direction: row;
&:hover {
background: var(--cards-background);
}
.newWebhookButton {
position: absolute;
right: 0;
top: -48px;
}

View file

@ -1,24 +1,14 @@
import React from 'react';
import {
Button,
ConfirmModal,
ConfirmModalProps,
HorizontalGroup,
Icon,
IconButton,
VerticalGroup,
WithContextMenu,
} from '@grafana/ui';
import { Button, ConfirmModal, ConfirmModalProps, HorizontalGroup, Icon, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import { LegacyNavHeading } from 'navbar/LegacyNavHeading';
import CopyToClipboard from 'react-copy-to-clipboard';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { GTable } from 'components/GTable/GTable';
import { HamburgerMenu } from 'components/HamburgerMenu/HamburgerMenu';
import { HamburgerContextMenu } from 'components/HamburgerContextMenu/HamburgerContextMenu';
import { LabelsTooltipBadge } from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
import { PageErrorHandlingWrapper, PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
@ -28,6 +18,8 @@ import {
import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { TextEllipsisTooltip } from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import { WebhookLastEventTimestamp } from 'components/Webhooks/WebhookLastEventTimestamp';
import { WebhookName } from 'components/Webhooks/WebhookName';
import { OutgoingWebhookForm } from 'containers/OutgoingWebhookForm/OutgoingWebhookForm';
import { RemoteFilters } from 'containers/RemoteFilters/RemoteFilters';
import { TeamName } from 'containers/TeamName/TeamName';
@ -127,7 +119,9 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
width: '25%',
title: 'Name',
dataIndex: 'name',
render: this.renderName,
render: (_name: string, webhook: OutgoingWebhook) => (
<WebhookName webhook={webhook} onNameClick={() => this.onEditClick(webhook.id)} />
),
},
{
width: '10%',
@ -143,7 +137,9 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
{
width: '10%',
title: 'Last event',
render: this.renderLastEvent,
render: (webhook: OutgoingWebhook) => (
<WebhookLastEventTimestamp webhook={webhook} openDrawer={() => this.onLastRunClick(webhook.id)} />
),
},
...(hasFeature(AppFeature.Labels)
? [
@ -190,6 +186,18 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
}
/>
)}
<div className={cx('newWebhookButton')}>
<PluginLink
query={{ page: 'outgoing_webhooks', id: 'new' }}
disabled={!isUserActionAllowed(UserActions.OutgoingWebhooksWrite)}
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
New Outgoing Webhook
</Button>
</WithPermissionControlTooltip>
</PluginLink>
</div>
<div className={cx('root')} data-testid="outgoing-webhooks-table">
{this.renderOutgoingWebhooksFilters()}
@ -202,18 +210,6 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
</LegacyNavHeading>
</div>
<div className="u-pull-right">
<PluginLink
query={{ page: 'outgoing_webhooks', id: 'new' }}
disabled={!isUserActionAllowed(UserActions.OutgoingWebhooksWrite)}
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
New Outgoing Webhook
</Button>
</WithPermissionControlTooltip>
</PluginLink>
</div>
</div>
)}
rowKey="id"
@ -274,127 +270,83 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
renderActionButtons = (record: OutgoingWebhook) => {
return (
<WithContextMenu
renderMenuItems={() => (
<div className={cx('hamburgerMenu')}>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onLastRunClick(record.id)}>
<WithPermissionControlTooltip key={'status_action'} userAction={UserActions.OutgoingWebhooksRead}>
<Text type="primary">View Last Run</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onEditClick(record.id)}>
<WithPermissionControlTooltip key={'edit_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Edit settings</Text>
</WithPermissionControlTooltip>
</div>
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDisableWebhook(record.id, !record.is_webhook_enabled),
title: `Are you sure you want to ${record.is_webhook_enabled ? 'disable' : 'enable'} webhook?`,
} as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'disable_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">{record.is_webhook_enabled ? 'Disable' : 'Enable'}</Text>
</WithPermissionControlTooltip>
</div>
<div className={cx('hamburgerMenu__item')} onClick={() => this.onCopyClick(record.id)}>
<WithPermissionControlTooltip key={'copy_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<Text type="primary">Make a copy</Text>
</WithPermissionControlTooltip>
</div>
<CopyToClipboard text={record.id} onCopy={() => openNotification('Webhook ID has been copied')}>
<div className={cx('hamburgerMenu__item')}>
<HorizontalGroup type="primary" spacing="xs">
<Icon name="clipboard-alt" />
<Text type="primary">UID: {record.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className={cx('thin-line-break')} />
<div
className={cx('hamburgerMenu__item')}
onClick={() =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDeleteClick(record.id),
body: 'The action cannot be undone.',
title: `Are you sure you want to delete webhook?`,
} as Partial<ConfirmModalProps> as ConfirmModalProps,
})
}
>
<WithPermissionControlTooltip key={'delete_action'} userAction={UserActions.OutgoingWebhooksWrite}>
<HorizontalGroup spacing="xs">
<IconButton tooltip="Remove" tooltipPlacement="top" variant="destructive" name="trash-alt" />
<Text type="danger">Delete Webhook</Text>
</HorizontalGroup>
</WithPermissionControlTooltip>
</div>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={225} withBackground />}
</WithContextMenu>
<HamburgerContextMenu
items={[
{
onClick: () => this.onLastRunClick(record.id),
requiredPermission: UserActions.OutgoingWebhooksRead,
label: <Text type="primary">View Last Event</Text>,
},
{
onClick: () => this.onEditClick(record.id),
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: <Text type="primary">Edit settings</Text>,
},
{
onClick: () =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDisableWebhook(record.id, !record.is_webhook_enabled),
title: `Are you sure you want to ${record.is_webhook_enabled ? 'disable' : 'enable'} webhook?`,
} as ConfirmModalProps,
}),
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: <Text type="primary">{record.is_webhook_enabled ? 'Disable' : 'Enable'}</Text>,
},
{
onClick: () => this.onCopyClick(record.id),
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: <Text type="primary">Make a copy</Text>,
},
{
label: (
<CopyToClipboard key="uid" text={record.id} onCopy={() => openNotification('Webhook ID has been copied')}>
<div>
<HorizontalGroup type="primary" spacing="xs">
<Icon name="clipboard-alt" />
<Text type="primary">UID: {record.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
),
},
'divider',
{
onClick: () =>
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onConfirm: () => this.onDeleteClick(record.id),
body: 'The action cannot be undone.',
title: `Are you sure you want to delete webhook?`,
} as Partial<ConfirmModalProps> as ConfirmModalProps,
}),
requiredPermission: UserActions.OutgoingWebhooksWrite,
label: (
<HorizontalGroup spacing="xs">
<IconButton tooltip="Remove" tooltipPlacement="top" variant="destructive" name="trash-alt" />
<Text type="danger">Delete Webhook</Text>
</HorizontalGroup>
),
},
]}
/>
);
};
renderName(name: String) {
return (
<div className="u-break-word">
<span>{name}</span>
</div>
);
}
renderUrl(url: string) {
return (
<TextEllipsisTooltip content={url} placement="top">
<CopyToClipboard text={url} onCopy={() => openNotification('URL has been copied')}>
<Text type="link" className={cx(TEXT_ELLIPSIS_CLASS, 'line-clamp-3')}>
{url}
</Text>
</CopyToClipboard>
<Text className={cx(TEXT_ELLIPSIS_CLASS, 'line-clamp-3')}>{url}</Text>
</TextEllipsisTooltip>
);
}
renderLastEvent(record: OutgoingWebhook) {
const lastEventMoment = moment(record.last_response_log?.timestamp);
return !record.is_webhook_enabled ? (
<Text type="secondary">Disabled</Text>
) : (
<VerticalGroup spacing="none">
<Text type="secondary">{lastEventMoment.isValid() ? lastEventMoment.format('MMM DD, YYYY') : '-'}</Text>
<Text type="secondary">{lastEventMoment.isValid() ? lastEventMoment.format('HH:mm') : ''}</Text>
<Text type="secondary">
{lastEventMoment.isValid()
? record.last_response_log?.status_code
? 'Status: ' + record.last_response_log?.status_code
: 'Check Status'
: ''}
</Text>
</VerticalGroup>
);
}
onDeleteClick = (id: OutgoingWebhook['id']): Promise<void> => {
const { store } = this.props;
return store.outgoingWebhookStore

View file

@ -104,7 +104,6 @@ export const Root = observer((props: AppRootProps) => {
<LegacyNavTabsBar currentPage={page} />
</>
)}
<div
className={classnames('u-position-relative', 'u-flex-grow-1', {
'u-overflow-x-auto': !isTopNavbar(),

View file

@ -18,6 +18,10 @@ class BaseLocationHelper {
}
}
}
getQueryParam(paramKey: string) {
return getQueryParams()?.[paramKey];
}
}
function toQueryString(queryParams: KeyValue) {

View file

@ -1,11 +1,15 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { ComponentProps, useEffect, useRef, useState } from 'react';
import { ConfirmModal, useStyles2 } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { ActionKey } from 'models/loader/action-keys';
import { LoaderHelper } from 'models/loader/loader.helpers';
import { useStore } from 'state/useStore';
import { LocationHelper } from './LocationHelper';
import { getCommonStyles } from './styles';
export function useForceUpdate() {
const [, setValue] = useState(0);
return () => setValue((value) => value + 1);
@ -74,6 +78,56 @@ export function useDebouncedCallback<A extends any[]>(callback: (...args: A) =>
};
}
export const useDrawer = <DrawerKey extends string, DrawerData = unknown>(initialDrawerData?: DrawerData) => {
const [openedDrawerKey, setOpenedDrawerKey] = useState<DrawerKey>(LocationHelper.getQueryParam('openedDrawerKey'));
const [drawerData, setDrawerData] = useState<DrawerData>(initialDrawerData);
return {
openDrawer: (drawerKey: DrawerKey, drawerData?: DrawerData) => {
setOpenedDrawerKey(drawerKey);
if (drawerData) {
setDrawerData(drawerData);
}
LocationHelper.update({ openedDrawerKey: drawerKey }, 'partial');
},
closeDrawer: () => {
setOpenedDrawerKey(undefined);
LocationHelper.update({ openedDrawerKey: undefined }, 'partial');
},
getIsDrawerOpened: (drawerKey: DrawerKey) => openedDrawerKey === drawerKey,
openedDrawerKey,
drawerData,
};
};
type ConfirmModalProps = ComponentProps<typeof ConfirmModal>;
export const useConfirmModal = () => {
const [modalProps, setModalProps] = useState<ConfirmModalProps>();
return {
openModal: (modalProps: Pick<ConfirmModalProps, 'title' | 'onConfirm'> & Partial<ConfirmModalProps>) => {
setModalProps({
isOpen: true,
confirmText: 'Confirm',
dismissText: 'Cancel',
onDismiss: () => setModalProps(undefined),
body: null,
...modalProps,
onConfirm: () => {
modalProps.onConfirm();
setModalProps(undefined);
},
});
},
closeModal: () => {
setModalProps(undefined);
},
modalProps,
};
};
export const useCommonStyles = () => useStyles2(getCommonStyles);
export const useIsLoading = (actionKey: ActionKey) => {
const { loaderStore } = useStore();
return LoaderHelper.isLoading(loaderStore, actionKey);

View file

@ -6,3 +6,12 @@ export function truncateTitle(title: string, length: number): string {
const part = title.slice(0, length - 3);
return `${part.trimEnd()}...`;
}
export const formatSourceCodeJsonString = (data: string) => {
try {
const jsonValue = JSON.parse(data);
return JSON.stringify(jsonValue, null, 4);
} catch (e) {
return data;
}
};

View file

@ -0,0 +1,9 @@
import { css } from '@emotion/css';
export const getCommonStyles = () => ({
bottomDrawerButtons: css({
position: 'absolute',
bottom: '16px',
right: '16px',
}),
});

View file

@ -29,13 +29,14 @@ const config = async (env): Promise<Configuration> => {
ignored: ['**/node_modules/', '**/dist'],
},
plugins: [
...(baseConfig.plugins?.filter((plugin) => !(plugin instanceof LiveReloadPlugin)) || []),
...(env.development ? [new LiveReloadPlugin({ appendScriptTag: true, useSourceHash: true })] : []),
new EnvironmentPlugin({
ONCALL_API_URL: null,
}),
new DefinePlugin({
'process.env': JSON.stringify(dotenv.config().parsed),
}),
...(env.development ? [new LiveReloadPlugin({ appendScriptTag: true, useSourceHash: true })] : []),
],
};
@ -49,7 +50,7 @@ const config = async (env): Promise<Configuration> => {
watchOptions: {
use: CustomizeRule.Merge,
},
plugins: CustomizeRule.Merge,
plugins: CustomizeRule.Replace,
})(baseConfig, customConfig);
};

File diff suppressed because it is too large Load diff