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:
parent
19b5c6553c
commit
c2ffd28675
56 changed files with 1913 additions and 1532 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
43
grafana-plugin/src/components/Webhooks/WebhookName.tsx
Normal file
43
grafana-plugin/src/components/Webhooks/WebhookName.tsx
Normal 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',
|
||||
}),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
margin: 4px;
|
||||
}
|
||||
|
||||
.tabs__content {
|
||||
.tabsWrapper {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
114
grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx
Normal file
114
grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ class BaseLocationHelper {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
getQueryParam(paramKey: string) {
|
||||
return getQueryParams()?.[paramKey];
|
||||
}
|
||||
}
|
||||
|
||||
function toQueryString(queryParams: KeyValue) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
9
grafana-plugin/src/utils/styles.ts
Normal file
9
grafana-plugin/src/utils/styles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { css } from '@emotion/css';
|
||||
|
||||
export const getCommonStyles = () => ({
|
||||
bottomDrawerButtons: css({
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
right: '16px',
|
||||
}),
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue