Merge pull request #1962 from grafana/dev

Merge dev to main
This commit is contained in:
Michael Derynck 2023-05-17 13:44:42 -06:00 committed by GitHub
commit 32d7cb032f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1597 additions and 1016 deletions

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.2.24 (2023-05-17)
### Fixed
- Fixed bug in Escalation Chains where reordering an item crashed the list
## v1.2.23 (2023-05-15)
### Added

View file

@ -48,7 +48,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
heartbeat = serializers.SerializerMethodField()
allow_delete = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False)
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
demo_alert_payload = serializers.SerializerMethodField()
routes_count = serializers.SerializerMethodField()
connected_escalations_chains_count = serializers.SerializerMethodField()

View file

@ -247,8 +247,9 @@ class GcomAPIClient(APIClient):
return self.api_get(query)
def is_stack_deleted(self, stack_id: str) -> bool:
instance_info = self.get_instance_info(stack_id)
return instance_info and instance_info.get("status") == self.STACK_STATUS_DELETED
url = f"instances?includeDeleted=true&id={stack_id}"
instance_infos, _ = self.api_get(url)
return instance_infos["items"] and instance_infos["items"][0].get("status") == self.STACK_STATUS_DELETED
def post_active_users(self, body):
return self.api_post("app-active-users", body)

View file

@ -80,7 +80,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
templates = serializers.DictField(required=False)
default_route = serializers.DictField(required=False)
heartbeat = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False)
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
PREFETCH_RELATED = ["channel_filters"]
SELECT_RELATED = ["organization", "integration_heartbeat"]

View file

@ -1,6 +1,7 @@
import logging
from celery.utils.log import get_task_logger
from django.apps import apps
from django.conf import settings
from django.utils import timezone
@ -126,12 +127,14 @@ def check_grafana_incident_is_enabled(client):
def delete_organization_if_needed(organization):
# Organization has a manually set API token, it will not be found within GCOM
# and would need to be deleted manually.
if organization.gcom_token is None:
logger.info(f"Organization {organization.pk} has no gcom_token. Probably it's needed to delete org manually.")
PluginAuthToken = apps.get_model("auth_token", "PluginAuthToken")
manually_provisioned_token = PluginAuthToken.objects.filter(organization_id=organization.pk).first()
if manually_provisioned_token:
logger.info(f"Organization {organization.pk} has PluginAuthToken. Probably it's needed to delete org manually.")
return False
# Use common token as organization.gcom_token could be already revoked
client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN)
client = GcomAPIClient(settings.GRAFANA_COM_ADMIN_API_TOKEN)
is_stack_deleted = client.is_stack_deleted(organization.stack_id)
if not is_stack_deleted:
return False

View file

@ -335,7 +335,7 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization):
def test_cleanup_organization_deleted(make_organization):
organization = make_organization(gcom_token="TEST_GCOM_TOKEN")
with patch.object(GcomAPIClient, "get_instance_info", return_value={"status": "deleted"}):
with patch.object(GcomAPIClient, "api_get", return_value=({"items": [{"status": "deleted"}]}, None)):
cleanup_organization(organization.id)
organization.refresh_from_db()

View file

@ -1,6 +1,6 @@
# Main
enabled = True
title = "AlertManager"
title = "Alertmanager"
slug = "alertmanager"
short_description = "Prometheus"
is_displayed_on_web = True

View file

@ -1,6 +1,6 @@
# Main
enabled = True
title = "Formatted Webhook"
title = "Formatted webhook"
slug = "formatted_webhook"
short_description = None
description = None

View file

@ -52,6 +52,6 @@ django-dbconn-retry==0.1.7
django-ipware==4.0.2
django-anymail==8.6
django-deprecate-fields==0.1.1
pymdown-extensions==9.11
pymdown-extensions==10.0
requests==2.29.0
urllib3==1.26.15

View file

@ -487,7 +487,8 @@ INTERNAL_IPS = ["127.0.0.1"]
SELF_IP = os.environ.get("SELF_IP")
SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False)
SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False) and not IS_IN_MAINTENANCE_MODE
if SILK_PROFILER_ENABLED:
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
SILKY_INTERCEPT_PERCENT = getenv_integer("SILKY_INTERCEPT_PERCENT", 100)

View file

@ -9,7 +9,7 @@ import { omit } from 'lodash-es';
import { templatesToRender, Template } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { getLabelFromTemplateName } from 'components/AlertTemplates/AlertTemplatesForm.helper';
import Block from 'components/GBlock/Block';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import TemplatePreview from 'containers/TemplatePreview/TemplatePreview';
@ -216,7 +216,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
</Button>
</Text>
)}
<MonacoJinja2Editor
<MonacoEditor
value={tempValues[activeTemplate.name] ?? (templates[activeTemplate.name] || '')}
disabled={false}
data={templates}

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { IconButton, IconName } from '@grafana/ui';
import { Icon, IconButton, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
import { isArray, isUndefined } from 'lodash-es';
@ -14,6 +14,8 @@ export interface IntegrationCollapsibleItem {
collapsedView: React.ReactNode;
isCollapsible: boolean;
isExpanded?: boolean;
canHoverIcon?: boolean;
onStateChange?(): void;
}
interface IntegrationCollapsibleTreeViewProps {
@ -25,6 +27,10 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
const [expandedList, setExpandedList] = useState(getStartingExpandedState());
useEffect(() => {
setExpandedList(getStartingExpandedState());
}, [configElements]);
return (
<div className={cx('integrationTree__container')}>
{configElements.map((item: IntegrationCollapsibleItem | IntegrationCollapsibleItem[], idx) => {
@ -34,7 +40,7 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
item={it}
key={`${idx}-${innerIdx}`}
onClick={() => expandOrCollapseAtPos(idx, innerIdx)}
isExpanded={!!expandedList[idx][innerIdx]}
isExpanded={expandedList[idx][innerIdx]}
/>
));
}
@ -44,7 +50,7 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
item={item}
key={idx}
onClick={() => expandOrCollapseAtPos(idx)}
isExpanded={!!expandedList[idx]}
isExpanded={expandedList[idx] as boolean}
/>
);
})}
@ -65,6 +71,18 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
}
function expandOrCollapseAtPos(i: number, j: number = undefined) {
if (j) {
let elem = configElements[i] as IntegrationCollapsibleItem[];
if (elem[j].onStateChange) {
elem[j].onStateChange();
}
} else {
let elem = configElements[i] as IntegrationCollapsibleItem;
if (elem.onStateChange) {
elem.onStateChange();
}
}
setExpandedList(
expandedList.map((elem, index) => {
if (!isUndefined(j) && index === i) {
@ -79,15 +97,22 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
}
};
const IntegrationCollapsibleTreeItem: React.FC<{ item: IntegrationCollapsibleItem; isExpanded: boolean; onClick }> = ({
item,
isExpanded,
onClick,
}) => {
const IntegrationCollapsibleTreeItem: React.FC<{
item: IntegrationCollapsibleItem;
isExpanded: boolean;
onClick: () => void;
canHoverIcon?: boolean;
}> = ({ item, isExpanded, onClick, canHoverIcon = true }) => {
const iconOnClickFn = !item.isCollapsible ? undefined : onClick;
return (
<div className={cx('integrationTree__group')}>
<div className={cx('integrationTree__icon')}>
<IconButton name={getIconName()} onClick={!item.isCollapsible ? undefined : onClick} size="lg" />
{canHoverIcon ? (
<IconButton name={getIconName()} onClick={iconOnClickFn} size="lg" />
) : (
<Icon name={getIconName()} onClick={iconOnClickFn} size="lg" />
)}
</div>
<div className={cx('integrationTree__element', { 'integrationTree__element--visible': isExpanded })}>
{item.expandedView}

View file

@ -0,0 +1,67 @@
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';
import styles from './IntegrationInputField.module.scss';
interface IntegrationInputFieldProps {
value: string;
isMasked?: boolean;
showEye?: boolean;
showCopy?: boolean;
showExternal?: boolean;
className?: string;
}
const cx = cn.bind(styles);
const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
isMasked = true,
value,
showEye = true,
showCopy = true,
showExternal = true,
className,
}) => {
const [isInputMasked, setIsMasked] = useState(isMasked);
return (
<div className={cx('root', { [className]: !!className })}>
<div className={cx('input-container')}>{renderInputField()}</div>
<div className={cx('icons')}>
<HorizontalGroup spacing={'xs'}>
{showEye && <IconButton name={'eye'} size={'xs'} onClick={onInputReveal} />}
{showCopy && (
<CopyToClipboard text={value} onCopy={onCopy}>
<IconButton name={'copy'} size={'xs'} />
</CopyToClipboard>
)}
{showExternal && <IconButton name={'external-link-alt'} size={'xs'} onClick={onOpen} />}
</HorizontalGroup>
</div>
</div>
);
function renderInputField() {
return <Input className={cx('input')} value={isInputMasked ? value?.replace(/./g, '*') : value} disabled />;
}
function onInputReveal() {
setIsMasked(!isInputMasked);
}
function onCopy() {
openNotification("Integration's HTTP Endpoint is copied!");
}
function onOpen() {
window.open(value, '_blank');
}
};
export default IntegrationInputField;

View file

@ -1,53 +0,0 @@
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';
import styles from './IntegrationMaskedInputField.module.scss';
interface IntegrationMaskedInputFieldProps {
value: string;
}
const cx = cn.bind(styles);
const IntegrationMaskedInputField: React.FC<IntegrationMaskedInputFieldProps> = ({ value }) => {
const [isMasked, setIsMasked] = useState(true);
return (
<div className={cx('root')}>
<div className={cx('input-container')}>{renderInputField()}</div>
<div className={cx('icons')}>
<HorizontalGroup spacing={'xs'}>
<IconButton name={'eye'} size={'xs'} onClick={onInputReveal} />
<CopyToClipboard text={value} onCopy={onCopy}>
<IconButton name={'copy'} size={'xs'} />
</CopyToClipboard>
<IconButton name={'external-link-alt'} size={'xs'} onClick={onOpen} />
</HorizontalGroup>
</div>
</div>
);
function renderInputField() {
return <Input className={cx('input')} value={isMasked ? value.replace(/./g, '*') : value} disabled />;
}
function onInputReveal() {
setIsMasked(!isMasked);
}
function onCopy() {
openNotification("Integration's HTTP Endpoint is copied!");
}
function onOpen() {
window.open(value, '_blank');
}
};
export default IntegrationMaskedInputField;

View file

@ -4,21 +4,29 @@ import { CodeEditor, CodeEditorSuggestionItemKind, LoadingPlaceholder } from '@g
import { getPaths } from 'utils';
import { conf, language } from './jinja2';
import { conf, language as jinja2Language } from './jinja2';
declare const monaco: any;
interface MonacoJinja2EditorProps {
interface MonacoEditorProps {
value: string;
disabled?: boolean;
height?: string;
focus?: boolean;
data: any;
showLineNumbers?: boolean;
useAutoCompleteList?: boolean;
language?: MONACO_LANGUAGE;
onChange?: (value: string) => void;
loading?: boolean;
monacoOptions?: any;
}
export enum MONACO_LANGUAGE {
json = 'json',
jinja2 = 'jinja2',
}
const PREDEFINED_TERMS = [
'grafana_oncall_link',
'integration_name',
@ -27,8 +35,20 @@ const PREDEFINED_TERMS = [
'tojson_pretty',
];
const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
const { value, onChange, disabled, data, height, monacoOptions, showLineNumbers = true, loading = false } = props;
const MonacoEditor: FC<MonacoEditorProps> = (props) => {
const {
value,
onChange,
disabled,
data,
language = MONACO_LANGUAGE.jinja2,
useAutoCompleteList = true,
focus = true,
height = '130px',
monacoOptions,
showLineNumbers = true,
loading = false,
} = props;
const autoCompleteList = useCallback(
() =>
@ -45,13 +65,17 @@ const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
onChange?.(editor.getValue());
});
editor.focus();
if (focus) {
editor.focus();
}
const jinja2Lang = monaco.languages.getLanguages().find((l: { id: string }) => l.id === 'jinja2');
if (!jinja2Lang) {
monaco.languages.register({ id: 'jinja2' });
monaco.languages.setLanguageConfiguration('jinja2', conf);
monaco.languages.setMonarchTokensProvider('jinja2', language);
if (language === MONACO_LANGUAGE.jinja2) {
const jinja2Lang = monaco.languages.getLanguages().find((l: { id: string }) => l.id === 'jinja2');
if (!jinja2Lang) {
monaco.languages.register({ id: 'jinja2' });
monaco.languages.setLanguageConfiguration('jinja2', conf);
monaco.languages.setMonarchTokensProvider('jinja2', jinja2Language);
}
}
}, []);
@ -59,6 +83,11 @@ const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
return <LoadingPlaceholder text="Loading..." />;
}
const otherProps: any = {};
if (useAutoCompleteList) {
otherProps.getSuggestions = { autoCompleteList };
}
return (
<CodeEditor
monacoOptions={monacoOptions}
@ -66,13 +95,13 @@ const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
readOnly={disabled}
showLineNumbers={showLineNumbers}
value={value}
language="jinja2"
language={language}
width="100%"
height={height ? `${height}` : `130px`}
height={height}
onEditorDidMount={handleMount}
getSuggestions={autoCompleteList}
{...otherProps}
/>
);
};
export default MonacoJinja2Editor;
export default MonacoEditor;

View file

@ -36,7 +36,11 @@ import styles from './EscalationPolicy.module.css';
const cx = cn.bind(styles);
export interface EscalationPolicyProps {
interface ElementSortableProps {
index: number;
}
export interface EscalationPolicyProps extends ElementSortableProps {
data: EscalationPolicyType;
waitDelays?: any[];
isDisabled?: boolean;

View file

@ -7,6 +7,7 @@ import styles from 'components/Tag/Tag.module.css';
interface TagProps {
color?: string;
className?: string;
border?: string;
children?: any;
onClick?: (ev) => void;
forwardedRef?: React.MutableRefObject<HTMLSpanElement>;
@ -15,13 +16,17 @@ interface TagProps {
const cx = cn.bind(styles);
const Tag: FC<TagProps> = (props) => {
const { children, color, className, onClick } = props;
const { children, color, className, border, onClick } = props;
const style: React.CSSProperties = {};
if (color) {
style.backgroundColor = color;
}
if (border) {
style.border = border;
}
return (
<span style={style} className={cx('root', className)} onClick={onClick} ref={props.forwardedRef}>
{children}

View file

@ -80,14 +80,19 @@ const Text: TextInterface = (props) => {
return (
<span
onClick={onClick}
className={cx('root', 'text', className, {
[`text--${type}`]: true,
[`text--${size}`]: true,
'text--strong': strong,
'text--underline': underline,
'no-wrap': !wrap,
keyboard,
})}
className={cx(
'root',
'text',
{
[`text--${type}`]: true,
[`text--${size}`]: true,
'text--strong': strong,
'text--underline': underline,
'no-wrap': !wrap,
keyboard,
},
className
)}
style={style}
{...rest}
>

View file

@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import AlertTemplatesForm from 'components/AlertTemplates/AlertTemplatesForm';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification } from 'utils';

View file

@ -6,10 +6,10 @@ import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import Block from 'components/GBlock/Block';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import IncidentMatcher from 'containers/IncidentMatcher/IncidentMatcher';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter, FilteringTermType } from 'models/channel_filter/channel_filter.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
@ -129,7 +129,7 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => {
</>
}
>
<MonacoJinja2Editor
<MonacoEditor
value={filteringTerm}
disabled={false}
onChange={handleFilteringTermChange}
@ -162,7 +162,7 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => {
disabled={data?.is_default}
error={errors['filtering_term']}
>
<MonacoJinja2Editor
<MonacoEditor
value={filteringTerm}
disabled={false}
onChange={handleFilteringTermChange}

View file

@ -7,7 +7,7 @@ import { observer } from 'mobx-react';
import { TemplateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import Block from 'components/GBlock/Block';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
@ -29,7 +29,9 @@ interface EditRegexpRouteTemplateModalProps {
const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateModalProps) => {
const { onHide, onUpdateRoute, channelFilterId, onOpenEditIntegrationTemplate, alertReceiveChannelId } = props;
const store = useStore();
const regexpBody = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term;
const [regexpTemplateBody, setRegexpTemplateBody] = useState<string>(regexpBody);
const templateJinja2Body = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term_as_jinja2;
@ -86,7 +88,7 @@ const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateMod
</HorizontalGroup>
<div className={cx('regexp-template-code')}>
<MonacoJinja2Editor
<MonacoEditor
value={regexpTemplateBody}
height={'200px'}
data={undefined}

View file

@ -76,6 +76,7 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
return (
<EscalationPolicy
index={index} // This in here is a MUST for the SortableElement
key={`item-${escalationPolicy.id}`}
data={escalationPolicy}
number={index + offset + 1}

View file

@ -1,4 +1,4 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
export function prepareForEdit(item: AlertReceiveChannel) {
return {

View file

@ -3,6 +3,7 @@ import React, { useState, useCallback, ChangeEvent } from 'react';
import { Drawer, VerticalGroup, HorizontalGroup, Input, Tag, EmptySearchResult, Button } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { useHistory } from 'react-router-dom';
import Collapse from 'components/Collapse/Collapse';
import Block from 'components/GBlock/Block';
@ -10,10 +11,14 @@ import GForm from 'components/GForm/GForm';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import Text from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types';
import {
AlertReceiveChannel,
AlertReceiveChannelOption,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { form } from './IntegrationForm2.config';
import { prepareForEdit } from './IntegrationForm2.helpers';
@ -24,14 +29,16 @@ const cx = cn.bind(styles);
interface IntegrationFormProps {
id: AlertReceiveChannel['id'] | 'new';
isTableView?: boolean;
onHide: () => void;
onUpdate: () => void;
}
const IntegrationForm2 = observer((props: IntegrationFormProps) => {
const { id, onHide, onUpdate } = props;
const { id, onHide, onUpdate, isTableView = true } = props;
const store = useStore();
const history = useHistory();
const { alertReceiveChannelStore, userStore } = store;
@ -40,6 +47,7 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
const [filterValue, setFilterValue] = useState('');
const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false);
const [selectedOption, setSelectedOption] = useState<AlertReceiveChannelOption>(undefined);
const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new');
const data =
id === 'new'
@ -48,7 +56,17 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
const handleSubmit = useCallback(
(data: Partial<AlertReceiveChannel>) => {
(id === 'new' ? alertReceiveChannelStore.create(data) : alertReceiveChannelStore.update(id, data)).then(() => {
(id === 'new'
? alertReceiveChannelStore
.create(data)
.then((response) => {
history.push(`${PLUGIN_ROOT}/integrations_2/${response.id}`);
})
.catch(() => {
openErrorNotification('Something went wrong, please try again later.');
})
: alertReceiveChannelStore.update(id, data)
).then(() => {
onHide();
onUpdate();
});
@ -60,6 +78,7 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
return () => {
setSelectedOption(option);
setShowNewIntegrationForm(true);
setShowIntegrarionsListDrawer(false);
};
}, []);
@ -77,7 +96,7 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
return (
<>
{id === 'new' && (
{showIntegrarionsListDrawer && (
<Drawer scrollableContent title="New Integration" onClose={onHide} closeOnMaskClick={false} width="640px">
<div className={cx('content')}>
<VerticalGroup>
@ -96,7 +115,7 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
<Block
bordered
hover
withBackground
shadowed
onClick={handleNewIntegrationOptionSelectCallback(alertReceiveChannelChoice)}
key={alertReceiveChannelChoice.value}
className={cx('card', { card_featured: alertReceiveChannelChoice.featured })}
@ -128,52 +147,54 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
</div>
</Drawer>
)}
{(showNewIntegrationForm || id !== 'new') && (
<Drawer
scrollableContent
title={id === 'new' ? `New ${selectedOption?.display_name} integration` : `Edit integration`}
onClose={onHide}
closeOnMaskClick={false}
width="640px"
>
{(showNewIntegrationForm || !showIntegrarionsListDrawer) && (
<Drawer scrollableContent title={getTitle()} onClose={onHide} closeOnMaskClick={false} width="640px">
<div className={cx('content')}>
<VerticalGroup>
<GForm form={form} data={data} onSubmit={handleSubmit} />
<Collapse
headerWithBackground
className={cx('collapse')}
isOpen={false}
label={<Text type="link">How the integration works</Text>}
contentClassName={cx('collapsable-content')}
>
<Text type="secondary">
The integration will generate the following:
<ul className={cx('integration-info-list')}>
<li className={cx('integration-info-item')}>Unique URL endpoint for receiving alerts </li>
<li className={cx('integration-info-item')}>
Templates to interpret alerts, tailored for Grafana Alerting{' '}
</li>
<li className={cx('integration-info-item')}>Grafana Alerting contact point </li>
<li className={cx('integration-info-item')}>Grafana Alerting notification</li>
</ul>
What youll need to do next:
<ul className={cx('integration-info-list')}>
<li className={cx('integration-info-item')}>
Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '}
</li>
<li className={cx('integration-info-item')}>
Set up routes that are based on alert content, such as severity, region, and service{' '}
</li>
<li className={cx('integration-info-item')}>Connect escalation chains to the routes</li>
<li className={cx('integration-info-item')}>
Review templates and personalize according to your requirements
</li>
</ul>
</Text>
</Collapse>
{isTableView && (
<Collapse
headerWithBackground
className={cx('collapse')}
isOpen={false}
label={<Text type="link">How the integration works</Text>}
contentClassName={cx('collapsable-content')}
>
<Text type="secondary">
The integration will generate the following:
<ul className={cx('integration-info-list')}>
<li className={cx('integration-info-item')}>Unique URL endpoint for receiving alerts </li>
<li className={cx('integration-info-item')}>
Templates to interpret alerts, tailored for Grafana Alerting{' '}
</li>
<li className={cx('integration-info-item')}>Grafana Alerting contact point </li>
<li className={cx('integration-info-item')}>Grafana Alerting notification</li>
</ul>
What youll need to do next:
<ul className={cx('integration-info-list')}>
<li className={cx('integration-info-item')}>
Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '}
</li>
<li className={cx('integration-info-item')}>
Set up routes that are based on alert content, such as severity, region, and service{' '}
</li>
<li className={cx('integration-info-item')}>Connect escalation chains to the routes</li>
<li className={cx('integration-info-item')}>
Review templates and personalize according to your requirements
</li>
</ul>
</Text>
</Collapse>
)}
<HorizontalGroup justify="flex-end">
{id === 'new' ? (
<Button variant="secondary" onClick={() => setShowNewIntegrationForm(false)}>
<Button
variant="secondary"
onClick={() => {
setShowNewIntegrationForm(false);
setShowIntegrarionsListDrawer(true);
}}
>
Back
</Button>
) : (
@ -194,6 +215,13 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => {
)}
</>
);
function getTitle(): string {
if (!isTableView) {
return 'Integration Settings';
}
return id === 'new' ? `New ${selectedOption?.display_name} integration` : `Edit integration`;
}
});
export default IntegrationForm2;

View file

@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect } from 'react';
import { Button, HorizontalGroup, VerticalGroup, Icon, Drawer } from '@grafana/ui';
import { Button, HorizontalGroup, Drawer, VerticalGroup, Icon } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -13,11 +13,12 @@ import {
webTitleTemplateCheatSheet,
} from 'components/CheatSheet/CheatSheet.config';
import Block from 'components/GBlock/Block';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import TemplatePreview from 'containers/TemplatePreview/TemplatePreview';
import TemplatesAlertGroupsList from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import LocationHelper from 'utils/LocationHelper';
@ -31,13 +32,14 @@ interface IntegrationTemplateProps {
channelFilterId?: ChannelFilter['id'];
template: TemplateForEdit;
templateBody: string;
templates: AlertTemplatesDTO[];
onHide: () => void;
onUpdateTemplates: (values: any) => void;
onUpdateRoute: (values: any, channelFilterId?: ChannelFilter['id']) => void;
}
const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const { id, onHide, template, onUpdateTemplates, onUpdateRoute, templateBody, channelFilterId } = props;
const { id, onHide, template, onUpdateTemplates, onUpdateRoute, templateBody, channelFilterId, templates } = props;
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState<boolean>(false);
const [chatOps, setChatOps] = useState(undefined);
@ -45,14 +47,11 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(templateBody);
const [resultError, setResultError] = useState<string>(undefined);
const locationParams: any = { template: template.name };
if (template.isRoute) {
locationParams.routeId = channelFilterId;
}
LocationHelper.update(locationParams, 'partial');
useEffect(() => {
const locationParams: any = { template: template.name };
if (template.isRoute) {
locationParams.routeId = channelFilterId;
}
LocationHelper.update(locationParams, 'partial');
}, []);
@ -67,7 +66,7 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const getChangeHandler = () => {
return debounce((value: string) => {
setChangedTemplateBody(value);
}, 1000);
}, 500);
};
const onEditPayload = (alertPayload: string) => {
@ -172,6 +171,7 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
alertReceiveChannelId={id}
onEditPayload={onEditPayload}
onSelectAlertGroup={onSelectAlertGroup}
templates={templates}
/>
{isCheatSheetVisible ? (
<CheatSheet cheatSheetData={getCheatSheet(template.displayName)} onClose={onCloseCheatSheet} />
@ -188,9 +188,9 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
</HorizontalGroup>
</div>
<MonacoJinja2Editor
<MonacoEditor
value={templateBody}
data={undefined}
data={templates}
showLineNumbers={true}
height={'85vh'}
onChange={getChangeHandler()}
@ -198,7 +198,6 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
</div>
</>
)}
{/* {alertGroupPayload || resultError ? ( */}
<Result
alertReceiveChannelId={id}
templateName={template.name}
@ -209,13 +208,6 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
error={resultError}
onSaveAndFollowLink={onSaveAndFollowLink}
/>
{/* ) : (
<div className={cx('template-block-result')}>
<div className={cx('template-block-title')}>
<Text>Please select Alert group to see end result</Text>
</div>
</div>
)} */}
</div>
</div>
</Drawer>

View file

@ -1,7 +1,8 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { cloneDeep } from 'lodash-es';
import { observer } from 'mobx-react';
import GForm from 'components/GForm/GForm';
@ -22,6 +23,7 @@ interface MaintenanceFormProps {
initialData: {
type?: MaintenanceType;
alert_receive_channel_id?: AlertReceiveChannel['id'];
disabled?: boolean;
};
onHide: () => void;
onUpdate: () => void;
@ -29,6 +31,7 @@ interface MaintenanceFormProps {
const MaintenanceForm = observer((props: MaintenanceFormProps) => {
const { onUpdate, onHide, initialData = {} } = props;
const maintenanceForm = useMemo(() => (initialData.disabled ? cloneDeep(form) : form), [initialData]);
const store = useStore();
@ -50,11 +53,20 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
.catch(showApiError);
}, []);
if (initialData.disabled) {
const alertReceiveChannelIdField = maintenanceForm.fields.find((f) => f.name === 'alert_receive_channel_id');
if (alertReceiveChannelIdField) {
// Integration page requires this field to be preset and disabled, therefore we add extra field `disabled` for the cloned form
alertReceiveChannelIdField.extra.disabled = true;
}
}
return (
<Drawer scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
<Drawer width="640px" scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
<div className={cx('content')}>
<VerticalGroup>
<GForm form={form} data={initialData} onSubmit={handleSubmit} />
<GForm form={maintenanceForm} data={initialData} onSubmit={handleSubmit} />
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel

View file

@ -55,7 +55,7 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -80,7 +80,7 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -2400,7 +2400,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -2425,7 +2425,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -2513,7 +2513,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -2538,7 +2538,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -2626,7 +2626,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -2651,7 +2651,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -2739,7 +2739,7 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -2764,7 +2764,7 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -2952,7 +2952,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -2977,7 +2977,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>
@ -3056,7 +3056,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -3081,7 +3081,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>

View file

@ -49,7 +49,7 @@ exports[`DownloadIcons it renders properly 1`] = `
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
iOS
</span>
@ -74,7 +74,7 @@ exports[`DownloadIcons it renders properly 1`] = `
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
class="root text text--primary text--medium icon-text"
>
Android
</span>

View file

@ -19,6 +19,24 @@
border-right: none;
}
.alert-groups-list {
padding-right: 16px;
}
.alert-groups-list button {
padding-left: 0;
}
.alert-groups-editor {
width: 100%;
}
.no-alert-groups-badge {
display: flex;
padding: 8px;
align-items: center;
}
.no-alert-groups-badge > div {
margin-right: 8px;
}

View file

@ -1,14 +1,24 @@
import React, { useEffect, useState } from 'react';
import { Button, HorizontalGroup, Tooltip, Icon, VerticalGroup, IconButton, Badge } from '@grafana/ui';
import {
Button,
HorizontalGroup,
Tooltip,
Icon,
VerticalGroup,
IconButton,
Badge,
LoadingPlaceholder,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import SourceCode from 'components/SourceCode/SourceCode';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { MONACO_PAYLOAD_OPTIONS } from 'pages/integration_2/Integration2.config';
import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
@ -16,13 +26,14 @@ import styles from './TemplatesAlertGroupsList.module.css';
const cx = cn.bind(styles);
interface TemplatesAlertGroupsListProps {
templates: AlertTemplatesDTO[];
alertReceiveChannelId: AlertReceiveChannel['id'];
onSelectAlertGroup?: (alertGroup: Alert) => void;
onEditPayload?: (payload: string) => void;
}
const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
const { alertReceiveChannelId, onEditPayload, onSelectAlertGroup } = props;
const { alertReceiveChannelId, templates, onEditPayload, onSelectAlertGroup } = props;
const store = useStore();
const [alertGroupsList, setAlertGroupsList] = useState(undefined);
const [selectedAlertPayload, setSelectedAlertPayload] = useState<string>(undefined);
@ -78,12 +89,15 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
<MonacoJinja2Editor
<MonacoEditor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={undefined}
data={templates}
height={'85vh'}
onChange={getChangeHandler()}
showLineNumbers
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
/>
</div>
</>
@ -102,9 +116,19 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
<div className={cx('alert-groups-list')}>
<VerticalGroup>
<Badge color="blue" text="Last alert payload" />
<SourceCode className={cx('alert-group-payload-view')} noMaxHeight showClipboardIconOnly>
{JSON.stringify(selectedAlertPayload, null, 4)}
</SourceCode>
<div className={cx('alert-groups-editor')}>
<MonacoEditor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={undefined}
disabled
height={'85vh'}
onChange={getChangeHandler()}
showLineNumbers
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
/>
</div>
</VerticalGroup>
</div>
</>
@ -124,12 +148,16 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
<MonacoJinja2Editor
<MonacoEditor
value={null}
data={undefined}
disabled={true}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
data={templates}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
showLineNumbers={false}
height={'85vh'}
onChange={getChangeHandler()}
showLineNumbers
/>
</div>
</>
@ -150,30 +178,37 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
{alertGroupsList?.length > 0 ? (
{alertGroupsList ? (
<>
{alertGroupsList.map((alertGroup) => {
return (
<div key={alertGroup.pk}>
<Button fill="text" onClick={() => getAlertGroupPayload(alertGroup.pk)}>
{getAlertGroupName(alertGroup)}
</Button>
</div>
);
})}
{alertGroupsList?.length > 0 ? (
<>
{alertGroupsList.map((alertGroup) => {
return (
<div key={alertGroup.pk}>
<Button fill="text" onClick={() => getAlertGroupPayload(alertGroup.pk)}>
{getAlertGroupName(alertGroup)}
</Button>
</div>
);
})}
</>
) : (
<Badge
color="blue"
text={
<div className={cx('no-alert-groups-badge')}>
<Icon name="info-circle" />
<Text>
This integration did not receive any alerts. Use custom payload example to preview
results.
</Text>
</div>
}
/>
)}
</>
) : (
<Badge
color="blue"
text={
<HorizontalGroup>
<Icon name="info-circle" />
<Text>
This integration did not receive any alerts. Use custom payload example to preview results.
</Text>
</HorizontalGroup>
}
/>
<LoadingPlaceholder text="Loading alert groups..." />
)}
</div>
</>

View file

@ -29,7 +29,7 @@ const UserDisplayWithAvatar = observer(({ id }: UserDisplayProps) => {
return (
<HorizontalGroup spacing="xs">
<Avatar size="small" src={user.avatar}></Avatar>
<Text type="secondary">{user.email}</Text>
<Text type="primary">{user.email}</Text>
</HorizontalGroup>
);
});

View file

@ -183,11 +183,11 @@ export const HeartIcon = (_props: IconProps) => (
<g>
<path
d="M222.5,453.7c6.1,6.1,14.3,9.5,22.9,9.5c8.5,0,16.9-3.5,22.9-9.5L448,274c27.3-27.3,42.3-63.6,42.4-102.1
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
/>
</g>
</svg>

View file

@ -1,4 +1,4 @@
import { AlertReceiveChannel } from './alert_receive_channel';
import { AlertReceiveChannel } from './alert_receive_channel/alert_receive_channel.types';
export interface ActionDTO {
id: string;

View file

@ -1,37 +0,0 @@
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { UserDTO as User } from './user';
export enum MaintenanceMode {
Debug,
Maintenance,
}
export interface AlertReceiveChannel {
id: string;
integration: string;
smile_code: string;
verbal_name: string;
description: string;
description_short: string;
author: User['pk'];
team: GrafanaTeam['id'];
created_at: string;
integration_url: string;
allow_source_based_resolving: boolean;
is_able_to_autoresolve: boolean;
default_channel_filter: number;
instructions: string;
demo_alert_enabled: boolean;
maintenance_mode?: MaintenanceMode;
maintenance_till?: number;
heartbeat: Heartbeat | null;
is_available_for_integration_heartbeat: boolean;
routes_count: number;
}
export interface AlertReceiveChannelChoice {
display_name: string;
value: number;
}

View file

@ -109,27 +109,6 @@ export class AlertReceiveChannelStore extends BaseStore {
@action
async updateItems(query: any = '') {
// const filters = typeof query === 'string' ? { search: query } : query;
// const { search } = filters;
// const { count, results } = await makeRequest(this.path, { params: { search, page } });
// this.items = {
// ...this.items,
// ...results.reduce(
// (acc: { [key: number]: AlertReceiveChannel }, item: AlertReceiveChannel) => ({
// ...acc,
// [item.id]: omit(item, 'heartbeat'),
// }),
// {}
// ),
// };
// this.searchResult = result.map((item: AlertReceiveChannel) => item.id);
// this.searchResult = {
// count,
// results: results.map((item: AlertReceiveChannel) => item.id),
// };
const params = typeof query === 'string' ? { search: query } : query;
const result = await makeRequest(this.path, { params });
@ -251,7 +230,7 @@ export class AlertReceiveChannelStore extends BaseStore {
};
if (isOverwrite) {
// This is needed because on Move Up/Down/Removal the store no longer reflects correct state
// This is needed because on Move Up/Down/Removal the store no longer reflects the correct state
this.channelFilters = {
...channelFilters,
};
@ -439,8 +418,18 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
async sendDemoAlert(id: AlertReceiveChannel['id']) {
await makeRequest(`${this.path}${id}/send_demo_alert/`, { method: 'POST' }).catch(showApiError);
async sendDemoAlert(id: AlertReceiveChannel['id'], payload: string = undefined) {
const requestConfig: any = {
method: 'POST',
};
if (payload) {
requestConfig.data = {
demo_alert_payload: payload,
};
}
await makeRequest(`${this.path}${id}/send_demo_alert/`, requestConfig).catch(showApiError);
Mixpanel.track('Send Demo Incident', null);
}

View file

@ -1,16 +1,28 @@
import { IRMPlanStatus } from 'models/alertgroup/alertgroup.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { UserDTO as User } from 'models/user';
import { User } from 'models/user/user.types';
export enum MaintenanceMode {
Debug = 0,
Maintenance = 1,
}
export interface AlertReceiveChannelOption {
display_name: string;
value: number;
featured: boolean;
short_description: string;
}
export interface AlertReceiveChannelCounters {
alerts_count: number;
alert_groups_count: number;
}
export interface AlertReceiveChannel {
id: string;
integration: any;
integration: string;
smile_code: string;
verbal_name: string;
description: string;
@ -25,23 +37,17 @@ export interface AlertReceiveChannel {
default_channel_filter: number;
instructions: string;
demo_alert_enabled: boolean;
demo_alert_payload: any;
maintenance_mode?: MaintenanceMode;
maintenance_till?: number;
heartbeat: Heartbeat | null;
is_available_for_integration_heartbeat: boolean;
routes_count: number;
allow_delete: boolean;
deleted?: boolean;
routes_count: number;
}
export interface AlertReceiveChannelOption {
export interface AlertReceiveChannelChoice {
display_name: string;
value: number;
featured: boolean;
short_description: string;
}
export interface AlertReceiveChannelCounters {
alerts_count: number;
alert_groups_count: number;
}

View file

@ -1,7 +1,7 @@
import { action, observable } from 'mobx';
import qs from 'query-string';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import BaseStore from 'models/base_store';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';

View file

@ -8,6 +8,7 @@ export interface ChannelFilter {
alert_receive_channel: AlertReceiveChannel['id'];
slack_channel_id?: SlackChannel['id'];
telegram_channel?: TelegramChannel['id'];
escalation_chain?: string;
created_at: string;
filtering_term: string;
is_default: boolean;

View file

@ -1,16 +1,15 @@
import React, { useState } from 'react';
import { ConfirmModal, HorizontalGroup, Icon } from '@grafana/ui';
import { ConfirmModal, HorizontalGroup, Icon, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
import styles from './CollapsedIntegrationRouteDisplay.module.scss';
import { RouteButtonsDisplay } from './ExpandedIntegrationRouteDisplay';
@ -40,16 +39,22 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
return (
<>
<IntegrationBlock
hasCollapsedBorder
hasCollapsedBorder={false}
key={channelFilterId}
heading={
<HorizontalGroup justify={'space-between'}>
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-primary')}>
{IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)}
</Tag>
<TooltipBadge
borderType="success"
text={IntegrationHelper.getRouteConditionWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
)}
tooltipTitle={undefined}
tooltipContent={undefined}
/>
{channelFilter.filtering_term && (
<Text type="link">{IntegrationHelper.truncateLine(channelFilter.filtering_term)}</Text>
<Text type="primary">{IntegrationHelper.truncateLine(channelFilter.filtering_term)}</Text>
)}
</HorizontalGroup>
<HorizontalGroup>
@ -77,15 +82,24 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
<HorizontalGroup>
<Icon name="list-ui-alt" />
<Text type="secondary">Escalate to</Text>
<PluginLink
className={cx('hover-button')}
target="_blank"
query={{ page: 'escalations', id: channelFilter.escalation_chain }}
>
<Text type="primary" strong>
{escalationChain?.name}
</Text>
</PluginLink>
{escalationChain?.name && (
<PluginLink
className={cx('hover-button')}
target="_blank"
query={{ page: 'escalations', id: channelFilter.escalation_chain }}
>
<Text type="primary" strong>
{escalationChain?.name}
</Text>
</PluginLink>
)}
{!escalationChain?.name && (
<IconButton
name="info-circle"
tooltip={'You have no selected escalation chain for this route'}
size={'md'}
/>
)}
</HorizontalGroup>
</HorizontalGroup>
</div>

View file

@ -5,20 +5,19 @@ import { Button, HorizontalGroup, InlineLabel, VerticalGroup, Icon, Tooltip, Con
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { ChatOpsConnectors } from 'containers/AlertRules/parts';
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
import GSelect from 'containers/GSelect/GSelect';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
import styles from './ExpandedIntegrationRouteDisplay.module.scss';
@ -35,11 +34,7 @@ interface ExpandedIntegrationRouteDisplayProps {
routeIndex: number;
templates: AlertTemplatesDTO[];
openEditTemplateModal: (templateName: string | string[], channelFilterId?: ChannelFilter['id']) => void;
onEditRegexpTemplate: (
templateRegexpBody: string,
templateJijja2Body: string,
channelFilterId: ChannelFilter['id']
) => void;
onEditRegexpTemplate: (channelFilterId: ChannelFilter['id']) => void;
}
interface ExpandedIntegrationRouteDisplayState {
@ -71,6 +66,11 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
return null;
}
const escalationChainRedirectObj: any = { page: 'escalations' };
if (channelFilter.escalation_chain) {
escalationChainRedirectObj.id = channelFilter.escalation_chain;
}
return (
<>
<IntegrationBlock
@ -79,9 +79,15 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
heading={
<HorizontalGroup justify={'space-between'}>
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-primary')}>
{IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)}
</Tag>
<TooltipBadge
borderType="success"
text={IntegrationHelper.getRouteConditionWording(
alertReceiveChannelStore.channelFilterIds[alertReceiveChannelId],
routeIndex
)}
tooltipTitle={undefined}
tooltipContent={undefined}
/>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<RouteButtonsDisplay
@ -95,35 +101,31 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
}
content={
<VerticalGroup spacing="xs">
{routeIndex !== channelFiltersTotal.length - 1 && (
<IntegrationBlockItem>
<HorizontalGroup spacing="xs">
<InlineLabel width={20} tooltip={'TODO: Add text'}>
Routing Template
</InlineLabel>
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(channelFilter.filtering_term, false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
<Button
variant={'secondary'}
icon="edit"
size={'md'}
onClick={() => handleEditRoutingTemplate(channelFilter, channelFilterId)}
<IntegrationBlockItem>
<HorizontalGroup spacing="xs">
<InlineLabel width={20}>Routing Template</InlineLabel>
<div className={cx('input', 'input--short')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(channelFilter.filtering_term, false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
<Button variant="secondary" size="md" onClick={undefined}>
<Text type="link">Help</Text>
<Icon name="angle-down" size="sm" />
</Button>
</HorizontalGroup>
</IntegrationBlockItem>
)}
</div>
<Button
variant={'secondary'}
icon="edit"
size={'md'}
onClick={() => handleEditRoutingTemplate(channelFilter, channelFilterId)}
/>
<Button variant="secondary" size="md" onClick={undefined}>
<Text type="link">Help</Text>
<Icon name="angle-down" size="sm" />
</Button>
</HorizontalGroup>
</IntegrationBlockItem>
{routeIndex !== channelFiltersTotal.length - 1 && (
<IntegrationBlockItem>
@ -177,23 +179,28 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
</WithPermissionControlTooltip>
<Button variant={'secondary'} icon={'sync'} size={'md'} onClick={onEscalationChainsRefresh} />
<PluginLink
className={cx('hover-button')}
target="_blank"
query={{ page: 'escalations', id: channelFilter.escalation_chain }}
>
<Button variant={'secondary'} icon={'external-link-alt'} size={'md'} />
<PluginLink className={cx('hover-button')} target="_blank" query={escalationChainRedirectObj}>
<Button
variant={'secondary'}
tooltip={channelFilter.escalation_chain ? 'Edit escalation chain' : 'Add escalation chain'}
icon={'external-link-alt'}
size={'md'}
/>
</PluginLink>
<Button
variant={'secondary'}
onClick={() => setState({ isEscalationCollapsed: !isEscalationCollapsed })}
>
<HorizontalGroup>
<Text type="link">Show escalation chain</Text>
{isEscalationCollapsed && <Icon name={'angle-right'} />}
{!isEscalationCollapsed && <Icon name={'angle-up'} />}
</HorizontalGroup>
</Button>
{channelFilter.escalation_chain && (
<Button
variant={'secondary'}
onClick={() => setState({ isEscalationCollapsed: !isEscalationCollapsed })}
>
<HorizontalGroup>
<Text type="link">{isEscalationCollapsed ? 'Show' : 'Hide'} escalation chain</Text>
{isEscalationCollapsed && <Icon name={'angle-right'} />}
{!isEscalationCollapsed && <Icon name={'angle-up'} />}
</HorizontalGroup>
</Button>
)}
</HorizontalGroup>
{isEscalationCollapsed && (
@ -242,7 +249,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
function handleEditRoutingTemplate(channelFilter, channelFilterId) {
if (channelFilter.filtering_term_type === 0) {
onEditRegexpTemplate(channelFilter.filtering_term, channelFilter.filtering_term_as_jinja2, channelFilterId);
onEditRegexpTemplate(channelFilterId);
} else {
openEditTemplateModal('route_template', channelFilterId);
}
@ -272,11 +279,11 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters);
return (
<HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
{routeIndex > 0 && !channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Tooltip placement="top" content={'Move Up'}>
<Button variant={'secondary'} onClick={onRouteMoveUp} icon={'arrow-up'} size={'xs'} />
<Button variant={'secondary'} onClick={onRouteMoveUp} icon={'arrow-up'} size={'sm'} />
</Tooltip>
</WithPermissionControlTooltip>
)}
@ -284,7 +291,7 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
{routeIndex < channelFiltersTotal.length - 2 && !channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Tooltip placement="top" content={'Move Down'}>
<Button variant={'secondary'} onClick={onRouteMoveDown} icon={'arrow-down'} size={'xs'} />
<Button variant={'secondary'} onClick={onRouteMoveDown} icon={'arrow-down'} size={'sm'} />
</Tooltip>
</WithPermissionControlTooltip>
)}
@ -292,7 +299,7 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
{!channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Tooltip placement="top" content={'Delete'}>
<Button variant={'secondary'} icon={'trash-alt'} size={'xs'} onClick={setRouteIdForDeletion} />
<Button variant={'secondary'} icon={'trash-alt'} size={'sm'} onClick={setRouteIdForDeletion} />
</Tooltip>
</WithPermissionControlTooltip>
)}

View file

@ -19,13 +19,14 @@ export const MONACO_OPTIONS = {
},
};
export const INTEGRATION_DEMO_PAYLOAD = {
alert_uid: '08d6891a-835c-e661-39fa-96b6a9e26552',
title: 'The whole system is down',
image_url: 'https://http.cat/500',
state: 'alerting',
link_to_upstream_details: 'https://en.wikipedia.org/wiki/Downtime',
message: 'Smth happened. Oh no!',
export const MONACO_PAYLOAD_OPTIONS = {
renderLineHighlight: false,
readOnly: false,
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};
export const MONACO_INPUT_HEIGHT_SMALL = '32px';

View file

@ -1,6 +1,6 @@
import dayjs from 'dayjs';
import { MaintenanceMode } from 'models/alert_receive_channel';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter';
import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './Integration2.config';
@ -31,7 +31,7 @@ const IntegrationHelper = {
return slice.length === line.length ? slice : `${slice} ...`;
},
getRouteConditionWording(channelFilters: { [id: string]: ChannelFilter }, routeIndex: number) {
getRouteConditionWording(channelFilters: Array<ChannelFilter['id']>, routeIndex: number) {
const totalCount = Object.keys(channelFilters).length;
if (routeIndex === totalCount - 1) {

View file

@ -1,35 +1,69 @@
$FLEX-GAP: 4px;
$MARGIN: 12px;
$ITEMS-MARGIN: 24px;
$LARGE-MARGIN: 24px;
.integration__heading-container,
.integration__subheading-container {
margin-bottom: $ITEMS-MARGIN;
}
.integration {
&__heading-container {
margin-bottom: calc($LARGE-MARGIN - 12px);
}
.integration__heading-container {
display: flex;
gap: $FLEX-GAP;
}
&__subheading-container {
margin-bottom: $LARGE-MARGIN;
}
.integration__heading {
display: flex;
justify-content: flex-end;
flex-direction: row;
width: 100%;
}
&__payloadInput {
width: 100%;
}
.integration__actions {
display: flex;
gap: $FLEX-GAP;
margin-left: auto;
}
&__alertsPanel {
padding-bottom: 12px;
}
.integration__actionsList {
display: flex;
flex-direction: column;
width: 160px;
border-radius: 2px;
&__name {
margin-bottom: 0;
}
&__heading-container {
display: flex;
gap: $FLEX-GAP;
}
&__heading {
display: flex;
justify-content: flex-end;
flex-direction: row;
width: 100%;
}
&__actions {
display: flex;
gap: $FLEX-GAP;
margin-left: auto;
}
&__actionsList {
display: flex;
flex-direction: column;
width: 160px;
border-radius: 2px;
}
&__description {
display: block;
margin-bottom: $LARGE-MARGIN;
}
&__counter {
font-size: 12px;
}
&__countersBadge {
line-height: 16px;
padding: 3px 4px;
}
&__input-field {
margin-right: 24px;
}
}
.integration__actionItem {
@ -51,11 +85,6 @@ $ITEMS-MARGIN: 24px;
}
}
.integration__description {
display: block;
margin-bottom: $MARGIN;
}
.hamburger-menu {
display: inline-flex;
flex-direction: column;
@ -72,16 +101,10 @@ $ITEMS-MARGIN: 24px;
color: var(--primary-text-color);
}
.integration__counter {
font-size: 12px;
}
.integration__countersBadge {
line-height: 16px;
padding: 3px 4px;
}
.loadingPlaceholder {
margin-bottom: 0;
margin-right: 4px;
animation: none;
}
.customise-button button {
@ -90,6 +113,14 @@ $ITEMS-MARGIN: 24px;
.routesSection {
padding-top: 20px;
&__heading {
font-size: 16px;
}
&__add {
margin-bottom: $LARGE-MARGIN;
}
}
.input {
@ -112,5 +143,24 @@ $ITEMS-MARGIN: 24px;
}
.heartbeat-badge {
padding: 4px 12px;
padding: 4px 8px;
}
.templates__content {
padding-left: 12px;
}
.templates__content,
.templates__container {
display: flex;
width: 100%;
align-items: center;
}
.templates__edit {
margin-left: 0;
}
.template-drawer {
padding-bottom: 24px;
}

View file

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import {
Button,
@ -10,37 +10,43 @@ import {
Modal,
CascaderOption,
IconButton,
ConfirmModal,
Drawer,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import CopyToClipboard from 'react-copy-to-clipboard';
import Emoji from 'react-emoji-render';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
import { debounce } from 'throttle-debounce';
import { TemplateForEdit, templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import IntegrationCollapsibleTreeView, {
IntegrationCollapsibleItem,
} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
import IntegrationInputField from 'components/IntegrationInputField/IntegrationInputField';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import IntegrationMaskedInputField from 'components/IntegrationMaskedInputField/IntegrationMaskedInputField';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import { initErrorDataState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import EditRegexpRouteTemplateModal from 'containers/EditRegexpRouteTemplateModal/EditRegexpRouteTemplateModal';
import IntegrationForm2 from 'containers/IntegrationForm/IntegrationForm2';
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
import MaintenanceForm from 'containers/MaintenanceForm/MaintenanceForm';
import TeamName from 'containers/TeamName/TeamName';
import UserDisplayWithAvatar from 'containers/UserDisplay/UserDisplayWithAvatar';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartGreenIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter';
import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { API_HOST, API_PATH_PREFIX } from 'network';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -52,9 +58,10 @@ import { DATASOURCE_ALERTING, PLUGIN_ROOT } from 'utils/consts';
import CollapsedIntegrationRouteDisplay from './CollapsedIntegrationRouteDisplay';
import ExpandedIntegrationRouteDisplay from './ExpandedIntegrationRouteDisplay';
import { INTEGRATION_DEMO_PAYLOAD, INTEGRATION_TEMPLATES_LIST } from './Integration2.config';
import { INTEGRATION_TEMPLATES_LIST, MONACO_PAYLOAD_OPTIONS } from './Integration2.config';
import IntegrationHelper from './Integration2.helper';
import styles from './Integration2.module.scss';
import Integration2HeartbeatForm from './Integration2HeartbeatForm';
import IntegrationBlock from './IntegrationBlock';
import IntegrationTemplateList from './IntegrationTemplatesList';
@ -68,12 +75,14 @@ interface Integration2State extends PageBaseState {
selectedTemplate: TemplateForEdit;
isEditRegexpRouteTemplateModalOpen: boolean;
channelFilterIdForEdit: ChannelFilter['id'];
isNewRoute: boolean;
isTemplateSettingsOpen: boolean;
newRoutes: string[];
isAddingRoute: boolean;
}
// This can be further improved by using a ref instead
const ACTIONS_LIST_WIDTH = 160;
const ACTIONS_LIST_BORDER = 2;
const NEW_ROUTE_DEFAULT = '{{ (payload.severity == "foo" and "bar" in payload.region) or True }}';
@observer
class Integration2 extends React.Component<Integration2Props, Integration2State> {
@ -87,7 +96,9 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
selectedTemplate: undefined,
isEditRegexpRouteTemplateModalOpen: false,
channelFilterIdForEdit: undefined,
isNewRoute: false,
isTemplateSettingsOpen: false,
newRoutes: [],
isAddingRoute: false,
};
}
@ -111,12 +122,11 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
render() {
const {
errorData,
isDemoModalOpen,
isEditTemplateModalOpen,
selectedTemplate,
isEditRegexpRouteTemplateModalOpen,
channelFilterIdForEdit,
isNewRoute,
isTemplateSettingsOpen,
} = this.state;
const {
store: { alertReceiveChannelStore, grafanaTeamStore },
@ -146,6 +156,29 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
<PageErrorHandlingWrapper errorData={errorData} objectName="integration" pageName="Integration">
{() => (
<div className={cx('root')}>
{isTemplateSettingsOpen && (
<Drawer
width="75%"
scrollableContent
title="Template Settings"
onClose={() => this.setState({ isTemplateSettingsOpen: false })}
closeOnMaskClick={false}
>
<IntegrationBlock
className={cx('template-drawer')}
hasCollapsedBorder
heading={undefined}
content={
<IntegrationTemplateList
alertReceiveChannelId={alertReceiveChannel.id}
openEditTemplateModal={this.openEditTemplateModal}
templates={templates}
/>
}
/>
</Drawer>
)}
<div className={cx('integration__heading-container')}>
<PluginLink query={{ page: 'integrations_2' }}>
<IconButton name="arrow-left" size="xxl" />
@ -154,75 +187,7 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
<Emoji text={alertReceiveChannel.verbal_name} />
</h1>
<div className={cx('integration__actions')}>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsTest}>
<Button
variant="secondary"
size="md"
onClick={() => this.setState({ isDemoModalOpen: true })}
data-testid="send-demo-alert"
>
Send demo alert
</Button>
</WithPermissionControlTooltip>
<WithContextMenu
renderMenuItems={({ closeMenu }) => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div
className={cx('integration__actionItem')}
onClick={() => this.openIntegrationSettings(id, closeMenu)}
>
<Text type="primary">Integration Settings</Text>
</div>
<div className={cx('integration__actionItem')} onClick={() => this.openHearbeat(id, closeMenu)}>
Hearbeat
</div>
<div
className={cx('integration__actionItem')}
onClick={() => this.openStartMaintenance(id, closeMenu)}
>
<Text type="primary">Start Maintenance</Text>
</div>
<div className="thin-line-break" />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')}>
<WithConfirm
title="Delete integration?"
body={
<>
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} />{' '}
integration?
</>
}
>
<div onClick={() => this.deleteIntegration(id, closeMenu)}>
<div
onClick={() => {
// work-around to prevent 2 modals showing (withContextMenu and ConfirmModal)
const contextMenuEl =
document.querySelector<HTMLElement>('#integration-menu-options');
if (contextMenuEl) {
contextMenuEl.style.display = 'none';
}
}}
>
<Text type="danger">Stop Maintenance</Text>
</div>
</div>
</WithConfirm>
</div>
</WithPermissionControlTooltip>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} />}
</WithContextMenu>
</div>
<IntegrationActions alertReceiveChannel={alertReceiveChannel} />
</div>
<div className={cx('integration__subheading-container')}>
@ -231,16 +196,23 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
{alertReceiveChannel.description_short}
</Text>
)}
<HorizontalGroup>
{alertReceiveChannelCounter && (
<TooltipBadge
borderType="primary"
tooltipTitle={undefined}
tooltipContent={this.getAlertReceiveChannelCounterTooltip()}
text={
alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count
}
/>
<PluginLink
className={cx('hover-button')}
target="_blank"
query={{ page: 'alert-groups', integration: alertReceiveChannel.id }}
>
<TooltipBadge
borderType="primary"
tooltipTitle={undefined}
tooltipContent={this.getAlertReceiveChannelCounterTooltip()}
text={
alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count
}
/>
</PluginLink>
)}
<TooltipBadge
@ -268,11 +240,9 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
<HorizontalGroup spacing="xs">
<Text type="secondary">Type:</Text>
<HorizontalGroup spacing="none">
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary" size="small">
{integration?.display_name}
</Text>
<Text type="primary">{integration?.display_name}</Text>
</HorizontalGroup>
</HorizontalGroup>
<HorizontalGroup spacing="xs">
@ -295,77 +265,87 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
expandedView: <HowToConnectComponent id={id} />,
},
{
customIcon: 'layer-group',
isExpanded: false,
isCollapsible: true,
collapsedView: (
isCollapsible: false,
canHoverIcon: false,
expandedView: (
<IntegrationBlock
hasCollapsedBorder
heading={
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-secondary')} className={cx('tag')}>
<div className={cx('templates__container')}>
<Tag
color={getVar('--tag-secondary-transparent')}
border={getVar('--border-weak')}
className={cx('tag')}
>
<Text type="primary" size="small">
Templates
</Text>
</Tag>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Grouping:</Text>
<Text type="link">
{IntegrationHelper.truncateLine(templates['grouping_id_template'] || '')}
</Text>
</HorizontalGroup>
<div className={cx('templates__content')}>
<HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Grouping:</Text>
<Text type="primary">
{IntegrationHelper.truncateLine(templates['grouping_id_template'] || '')}
</Text>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Autoresolve:</Text>
<Text type="link">
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')}
</Text>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Autoresolve:</Text>
<Text type="primary">
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')}
</Text>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Visualisation:</Text>
<Text type="primary">Multiple</Text>
</HorizontalGroup>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Visualisation:</Text>
<Text type="primary">Multiple</Text>
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('templates__edit')}>
<Button
variant={'secondary'}
icon="edit"
size={'sm'}
tooltip="Edit"
onClick={() => this.setState({ isTemplateSettingsOpen: true })}
/>
</div>
</div>
</div>
}
content={null}
/>
),
expandedView: (
<IntegrationBlock
hasCollapsedBorder
heading={
<HorizontalGroup>
<Tag color={getVar('--tag-secondary')} className={cx('tag')}>
<Text type="primary" size="small">
Templates
</Text>
</Tag>
</HorizontalGroup>
}
content={
<IntegrationTemplateList
getTemplatesList={this.getTemplatesList}
openEditTemplateModal={this.openEditTemplateModal}
templates={templates}
/>
}
/>
),
collapsedView: undefined,
},
{
customIcon: 'plus',
customIcon: 'code-branch',
isCollapsible: false,
collapsedView: null,
canHoverIcon: false,
expandedView: (
<div className={cx('routesSection')}>
<VerticalGroup spacing="md">
<Text type={'primary'}>Routes</Text>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Button variant={'primary'} onClick={this.handleAddNewRoute}>
Add route
</Button>
</WithPermissionControlTooltip>
<Text type={'primary'} className={cx('routesSection__heading')}>
Routes
</Text>
<HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Button
variant={'primary'}
className={cx('routesSection__add')}
onClick={this.handleAddNewRoute}
>
Add route
</Button>
</WithPermissionControlTooltip>
{this.state.isAddingRoute && <LoadingPlaceholder text="Loading..." />}
</HorizontalGroup>
</VerticalGroup>
</div>
),
@ -374,30 +354,25 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
]}
/>
<IntegrationSendDemoPayloadModal
alertReceiveChannel={alertReceiveChannel}
isOpen={isDemoModalOpen}
onHideOrCancel={() => this.setState({ isDemoModalOpen: false })}
/>
{isEditTemplateModalOpen && (
<IntegrationTemplate
id={id}
onHide={() => {
this.setState({
isEditTemplateModalOpen: undefined,
isNewRoute: false,
});
LocationHelper.update({ template: undefined, routeId: undefined }, 'partial');
}}
channelFilterId={channelFilterIdForEdit}
onUpdateTemplates={this.onUpdateTemplatesCallback}
onUpdateRoute={isNewRoute ? this.onCreateRoutesCallback : this.onUpdateRoutesCallback}
onUpdateRoute={this.onUpdateRoutesCallback}
template={selectedTemplate}
templateBody={
selectedTemplate?.name === 'route_template'
? this.getRoutingTemplate(isNewRoute, channelFilterIdForEdit)
? this.getRoutingTemplate(channelFilterIdForEdit)
: templates[selectedTemplate?.name]
}
templates={templates}
/>
)}
{isEditRegexpRouteTemplateModalOpen && (
@ -416,19 +391,46 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
);
}
getRoutingTemplate = (isRouteNew: boolean, channelFilterId: ChannelFilter['id']) => {
getRoutingTemplate = (channelFilterId: ChannelFilter['id']) => {
const {
store: { alertReceiveChannelStore },
} = this.props;
if (isRouteNew) {
return '{{ (payload.severity == "foo" and "bar" in payload.region) or True }}';
} else {
return alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term;
}
return alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term;
};
handleAddNewRoute = () => {
this.setState({ isNewRoute: true });
this.openEditTemplateModal('route_template');
const { alertReceiveChannelStore, escalationPolicyStore } = this.props.store;
const {
params: { id },
} = this.props.match;
this.setState(
{
isAddingRoute: true,
},
() => {
alertReceiveChannelStore
.createChannelFilter({
order: 0,
alert_receive_channel: id,
filtering_term: NEW_ROUTE_DEFAULT,
filtering_term_type: 1, // non-regex
})
.then(async (channelFilter: ChannelFilter) => {
this.setState({ isAddingRoute: false, newRoutes: this.state.newRoutes.concat(channelFilter.id) });
await alertReceiveChannelStore.updateChannelFilters(id, true);
await escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
openNotification('A new route has been added');
})
.catch((err) => {
const errors = get(err, 'response.data');
if (errors?.non_field_errors) {
openErrorNotification(errors.non_field_errors);
}
});
}
);
};
renderRoutesFn = (): IntegrationCollapsibleItem[] => {
@ -442,27 +444,37 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
const templates = alertReceiveChannelStore.templates[id];
const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id];
return channelFilterIds.map((channelFilterId: ChannelFilter['id'], routeIndex: number) => ({
isCollapsible: true,
isExpanded: false,
collapsedView: (
<CollapsedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
/>
),
expandedView: (
<ExpandedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
templates={templates}
openEditTemplateModal={this.openEditTemplateModal}
onEditRegexpTemplate={this.handleEditRegexpRouteTemplate}
/>
),
}));
return channelFilterIds.map(
(channelFilterId: ChannelFilter['id'], routeIndex: number) =>
({
isCollapsible: true,
// this will keep new routes expanded at the very first time
isExpanded: this.state.newRoutes.indexOf(channelFilterId) > -1 ? true : false,
onStateChange: () => {
if (this.state.newRoutes.indexOf(channelFilterId) > -1) {
// this will close them on user action
this.setState((prevState) => ({ newRoutes: prevState.newRoutes.filter((r) => r !== channelFilterId) }));
}
},
collapsedView: (
<CollapsedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
/>
),
expandedView: (
<ExpandedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
templates={templates}
openEditTemplateModal={this.openEditTemplateModal}
onEditRegexpTemplate={this.handleEditRegexpRouteTemplate}
/>
),
} as IntegrationCollapsibleItem)
);
};
renderHearbeat = (alertReceiveChannel: AlertReceiveChannel) => {
@ -473,13 +485,20 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
const heartbeatStatus = Boolean(heartbeat?.status);
if (
!alertReceiveChannel.is_available_for_integration_heartbeat ||
alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal === null
) {
return null;
}
return (
<TooltipBadge
text={undefined}
className={cx('heartbeat-badge')}
borderType={alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartGreenIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal || 'never'}`}
borderType={heartbeatStatus ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal}`}
tooltipContent={undefined}
/>
);
@ -507,38 +526,9 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
this.setState({ isEditRegexpRouteTemplateModalOpen: true, channelFilterIdForEdit: channelFilterId });
};
onCreateRoutesCallback = ({ route_template }: { route_template: string }) => {
const { alertReceiveChannelStore, escalationPolicyStore } = this.props.store;
const {
params: { id },
} = this.props.match;
alertReceiveChannelStore
.createChannelFilter({
order: 0,
alert_receive_channel: id,
filtering_term: route_template,
// TODO: need to figure out this value
filtering_term_type: 1,
})
.then((channelFilter: ChannelFilter) => {
alertReceiveChannelStore.updateChannelFilters(id, true).then(() => {
// @ts-ignore
escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
});
})
.catch((err) => {
const errors = get(err, 'response.data');
if (errors?.non_field_errors) {
openErrorNotification(errors.non_field_errors);
}
});
};
onUpdateRoutesCallback = (
{ route_template }: { route_template: string },
channelFilterId,
channelFilterId: ChannelFilter['id'],
filteringTermType?: number
) => {
const { alertReceiveChannelStore, escalationPolicyStore } = this.props.store;
@ -549,13 +539,10 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
alertReceiveChannelStore
.saveChannelFilter(channelFilterId, {
filtering_term: route_template,
// TODO: need to figure out this value
filtering_term_type: filteringTermType,
})
.then((channelFilter: ChannelFilter) => {
alertReceiveChannelStore.updateChannelFilters(id, true).then(() => {
// @ts-ignore
escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
});
})
@ -592,8 +579,14 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
getTemplatesList = (): CascaderOption[] => INTEGRATION_TEMPLATES_LIST;
openEditTemplateModal = (templateName, channelFilterId?: ChannelFilter['id']) => {
this.setState({ selectedTemplate: templateForEdit[templateName] });
this.setState({ isEditTemplateModalOpen: true });
if (templateForEdit[templateName]) {
this.setState({
isEditTemplateModalOpen: true,
selectedTemplate: templateForEdit[templateName],
});
} else {
openErrorNotification('Template can not be edited. Please contact support.');
}
if (channelFilterId) {
this.setState({ channelFilterIdForEdit: channelFilterId });
@ -609,14 +602,6 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
alertReceiveChannelStore.deleteAlertReceiveChannel(id).then(() => history.push(`${PLUGIN_ROOT}/integrations_2/`));
};
deleteIntegration = (_id: AlertReceiveChannel['id'], _closeMenu: () => void) => {};
openIntegrationSettings = (_id: AlertReceiveChannel['id'], _closeMenu: () => void) => {};
openStartMaintenance = (_id: AlertReceiveChannel['id'], _closeMenu: () => void) => {};
openHearbeat = (_id: AlertReceiveChannel['id'], _closeMenu: () => void) => {};
async loadIntegration() {
const {
store: { alertReceiveChannelStore },
@ -687,9 +672,14 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
onHideOrCancel,
}) => {
const { alertReceiveChannelStore } = useStore();
const [demoPayload, setDemoPayload] = useState<string>(
JSON.stringify(alertReceiveChannel.demo_alert_payload, null, '\t')
);
let onPayloadChangeDebounced = debounce(100, onPayloadChange);
return (
<Modal
closeOnBackdropClick={false}
closeOnEscape
isOpen={isOpen}
onDismiss={onHideOrCancel}
@ -698,12 +688,31 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
<VerticalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type={'secondary'}>Alert Payload</Text>
<Tooltip content={'TODO'} placement={'top-start'}>
<Tooltip
content={
<>
A demo alert will be generated. You can find it on the <strong>Alert Groups</strong> page
</>
}
placement={'top-start'}
>
<Icon name={'info-circle'} />
</Tooltip>
</HorizontalGroup>
<SourceCode showCopyToClipboard={false}>{getDemoAlertJSON()}</SourceCode>
<div className={cx('integration__payloadInput')}>
<MonacoEditor
value={JSON.stringify(alertReceiveChannel.demo_alert_payload, null, '\t')}
disabled={true}
height={`200px`}
useAutoCompleteList={false}
language={MONACO_LANGUAGE.json}
data={undefined}
monacoOptions={MONACO_PAYLOAD_OPTIONS}
showLineNumbers={false}
onChange={onPayloadChangeDebounced}
/>
</div>
<HorizontalGroup justify={'flex-end'} spacing={'md'}>
<Button variant={'secondary'} onClick={onHideOrCancel}>
@ -720,8 +729,17 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
</Modal>
);
function onPayloadChange(value: string) {
setDemoPayload(value);
}
function sendDemoAlert() {
alertReceiveChannelStore.sendDemoAlert(alertReceiveChannel.id).then(() => {
let parsedPayload = undefined;
try {
parsedPayload = JSON.parse(demoPayload);
} catch (ex) {}
alertReceiveChannelStore.sendDemoAlert(alertReceiveChannel.id, parsedPayload).then(() => {
alertReceiveChannelStore.updateCounters();
openNotification(<DemoNotification />);
onHideOrCancel();
@ -729,14 +747,210 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
}
function getCurlText() {
// TODO add this
return `curl -X POST [URL]
-H "Content-Type: application/json"
-d "[JSON data]"`;
return (
`curl '${API_HOST}${API_PATH_PREFIX}${API_PATH_PREFIX}/alert_receive_channels/${alertReceiveChannel.id}/send_demo_alert/'` +
` -XPOST -H 'Content-Type: application/json'` +
`--data-raw '{"demo_alert_payload":{"alerts":[{"a":"b"}]}}' --compressed`
);
}
};
interface IntegrationActionsProps {
alertReceiveChannel: AlertReceiveChannel;
}
const IntegrationActions: React.FC<IntegrationActionsProps> = ({ alertReceiveChannel }) => {
const { maintenanceStore, alertReceiveChannelStore } = useStore();
const history = useHistory();
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: any;
dismissText: string;
confirmText: string;
body?: React.ReactNode;
description?: string;
confirmationText?: string;
onConfirm: () => void;
}>(undefined);
const [isIntegrationSettingsOpen, setIsIntegrationSettingsOpen] = useState(false);
const [isHearbeatFormOpen, setIsHearbeatFormOpen] = useState(false);
const [isDemoModalOpen, setIsDemoModalOpen] = useState(false);
const [maintenanceData, setMaintenanceData] = useState<{
disabled: boolean;
alert_receive_channel_id: AlertReceiveChannel['id'];
}>(undefined);
const { id } = alertReceiveChannel;
return (
<>
{confirmModal && (
<ConfirmModal
isOpen={confirmModal.isOpen}
title={confirmModal.title}
confirmText={confirmModal.confirmText}
dismissText="Cancel"
body={confirmModal.body}
description={confirmModal.description}
confirmationText={confirmModal.confirmationText}
onConfirm={confirmModal.onConfirm}
onDismiss={() => setConfirmModal(undefined)}
/>
)}
{alertReceiveChannel.demo_alert_enabled && (
<IntegrationSendDemoPayloadModal
alertReceiveChannel={alertReceiveChannel}
isOpen={isDemoModalOpen}
onHideOrCancel={() => setIsDemoModalOpen(false)}
/>
)}
{isIntegrationSettingsOpen && (
<IntegrationForm2
isTableView={false}
onHide={() => setIsIntegrationSettingsOpen(false)}
onUpdate={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
id={alertReceiveChannel['id']}
/>
)}
{isHearbeatFormOpen && (
<Integration2HeartbeatForm
alertReceveChannelId={alertReceiveChannel['id']}
onClose={() => setIsHearbeatFormOpen(false)}
/>
)}
{maintenanceData && (
<MaintenanceForm
initialData={maintenanceData}
onUpdate={() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id)}
onHide={() => setMaintenanceData(undefined)}
/>
)}
<div className={cx('integration__actions')}>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsTest}>
<Button
variant="secondary"
size="md"
onClick={() => setIsDemoModalOpen(true)}
data-testid="send-demo-alert"
disabled={!alertReceiveChannel.demo_alert_enabled}
tooltip={alertReceiveChannel.demo_alert_enabled ? '' : 'Demo Alerts are not enabled for this integration'}
>
Send demo alert
</Button>
</WithPermissionControlTooltip>
<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>
<div className={cx('integration__actionItem')} onClick={() => setIsHearbeatFormOpen(true)}>
Hearbeat
</div>
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')} onClick={openStartMaintenance}>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
{alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')}>
<div
onClick={() => {
setConfirmModal({
isOpen: true,
confirmText: 'Stop',
dismissText: 'Cancel',
onConfirm: onStopMaintenance,
title: (
<>
Are you sure you want to stop the maintenance for{' '}
<Emoji text={alertReceiveChannel.verbal_name} />?
</>
),
});
}}
>
<Text type="primary">Stop Maintenance</Text>
</div>
</div>
</WithPermissionControlTooltip>
)}
<div className="thin-line-break" />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')}>
<div
onClick={() => {
setConfirmModal({
isOpen: true,
title: (
<>
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} />{' '}
integration?
</>
),
body: <>This action cannot be undone.</>,
onConfirm: deleteIntegration,
dismissText: 'Cancel',
confirmText: 'Delete',
});
}}
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
</div>
</WithPermissionControlTooltip>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} />}
</WithContextMenu>
</div>
</>
);
function deleteIntegration() {
alertReceiveChannelStore
.deleteAlertReceiveChannel(alertReceiveChannel.id)
.then(() => history.push(`${PLUGIN_ROOT}/integrations_2`));
}
function getDemoAlertJSON() {
return JSON.stringify(INTEGRATION_DEMO_PAYLOAD, null, 4);
function openIntegrationSettings() {
setIsIntegrationSettingsOpen(true);
}
function openStartMaintenance() {
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
}
function onStopMaintenance() {
setConfirmModal(undefined);
maintenanceStore
.stopMaintenanceMode(MaintenanceType.alert_receive_channel, id)
.then(() => maintenanceStore.updateMaintenances())
.then(() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id));
}
};
@ -752,14 +966,21 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
hasCollapsedBorder={false}
heading={
<div className={cx('how-to-connect__container')}>
<Tag color={getVar('--tag-secondary')} className={cx('how-to-connect__tag')}>
<Tag
color={getVar('--tag-secondary-transparent')}
border={getVar('--border-weak')}
className={cx('how-to-connect__tag')}
>
<Text type="primary" size="small">
HTTP Endpoint
</Text>
</Tag>
<IntegrationMaskedInputField value={alertReceiveChannelStore.items[id].integration_url} />
<IntegrationInputField
value={alertReceiveChannelStore.items[id].integration_url}
className={cx('integration__input-field')}
/>
<a href="https://grafana.com/docs/oncall/latest/integrations/" target="_blank" rel="noreferrer">
<Text type="link" size="small" onClick={openHowToConnect}>
<Text type="link" size="small">
<HorizontalGroup>
How to connect
<Icon name="external-link-alt" />
@ -772,15 +993,13 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
/>
);
function openHowToConnect() {}
function renderContent() {
return (
<div className={cx('integration__alertsPanel')}>
<VerticalGroup justify={'flex-start'} spacing={'xs'}>
{!hasAlerts && (
<HorizontalGroup spacing={'xs'}>
<LoadingPlaceholder text="" className={cx('loadingPlaceholder')} />
<Icon name="fa fa-spinner" size="md" className={cx('loadingPlaceholder')} />
<Text type={'primary'}>No alerts yet; try to send a demo alert</Text>
</HorizontalGroup>
)}

View file

@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { Button, Drawer, Field, HorizontalGroup, Select, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import IntegrationInputField from 'components/IntegrationInputField/IntegrationInputField';
import Text from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
const cx = cn.bind({});
interface Integration2HearbeatFormProps {
alertReceveChannelId: AlertReceiveChannel['id'];
onClose?: () => void;
}
const Integration2HearbeatForm = observer(({ alertReceveChannelId, onClose }: Integration2HearbeatFormProps) => {
const [interval, setInterval] = useState<number>(undefined);
const { heartbeatStore, alertReceiveChannelStore } = useStore();
const alertReceiveChannel = alertReceiveChannelStore.items[alertReceveChannelId];
useEffect(() => {
heartbeatStore.updateTimeoutOptions();
}, [heartbeatStore]);
useEffect(() => {
if (alertReceiveChannel.heartbeat) {
setInterval(alertReceiveChannel.heartbeat.timeout_seconds);
}
}, [alertReceiveChannel]);
const timeoutOptions = heartbeatStore.timeoutOptions;
return (
<Drawer width={'640px'} scrollableContent title={'Heartbeat'} onClose={onClose} closeOnMaskClick={false}>
<VerticalGroup spacing={'lg'}>
<Text type="secondary">
Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may
trigger false alarms.
</Text>
<VerticalGroup spacing="md">
<div className={cx('u-width-100')}>
<Field label={'Setup heartbeat interval'}>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Select
className={cx('select', 'timeout')}
onChange={(value: SelectableValue) => setInterval(value.value)}
placeholder="Heartbeat Timeout"
value={interval}
options={(timeoutOptions || []).map((timeoutOption: SelectOption) => ({
value: timeoutOption.value,
label: timeoutOption.display_name,
}))}
/>
</WithPermissionControlTooltip>
</Field>
</div>
<div className={cx('u-width-100')}>
<Field label="Endpoint" description="Use the following unique Grafana link to send GET and POST requests">
<IntegrationInputField value={alertReceiveChannel?.integration_url} showEye={false} isMasked={false} />
</Field>
</div>
</VerticalGroup>
<VerticalGroup style={{ marginTop: 'auto' }}>
<HorizontalGroup className={cx('buttons')} justify="flex-end">
<Button variant={'secondary'} onClick={onClose}>
Cancel
</Button>
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<Button variant="primary" onClick={onSave}>
{alertReceiveChannel.heartbeat ? 'Save' : 'Create'}
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
</VerticalGroup>
</Drawer>
);
async function onSave() {
const heartbeat = alertReceiveChannel.heartbeat;
if (heartbeat) {
await heartbeatStore.saveHeartbeat(heartbeat.id, {
alert_receive_channel: heartbeat.alert_receive_channel,
timeout_seconds: interval,
});
onClose();
} else {
await heartbeatStore.createHeartbeat(alertReceveChannelId, {
timeout_seconds: interval,
});
onClose();
}
await alertReceiveChannelStore.updateItem(alertReceveChannelId);
}
});
export default withMobXProviderContext(Integration2HearbeatForm) as ({
alertReceveChannelId,
}: Integration2HearbeatFormProps) => JSX.Element;

View file

@ -11,10 +11,10 @@
.integrationBlock__content {
background: var(--background-primary);
border: var(--border-weak);
padding-bottom: 0;
&--collapsedBorder {
border-left: none;
padding-left: 0;
padding-bottom: 0;
}
}

View file

@ -9,17 +9,20 @@ import styles from './IntegrationBlock.module.scss';
const cx = cn.bind(styles);
interface IntegrationBlockProps {
className?: string;
hasCollapsedBorder: boolean;
heading: React.ReactNode;
content: React.ReactNode;
}
const IntegrationBlock: React.FC<IntegrationBlockProps> = ({ heading, content, hasCollapsedBorder }) => {
const IntegrationBlock: React.FC<IntegrationBlockProps> = ({ heading, content, hasCollapsedBorder, className }) => {
return (
<div className={cx('integrationBlock')}>
<Block bordered shadowed className={cx('integrationBlock__heading')}>
{heading}
</Block>
<div className={cx('integrationBlock', className)}>
{heading && (
<Block bordered shadowed className={cx('integrationBlock__heading')}>
{heading}
</Block>
)}
{content && (
<div
className={cx('integrationBlock__content', {

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Button, HorizontalGroup, Icon, InlineLabel } from '@grafana/ui';
import { Button, HorizontalGroup, Icon, InlineLabel, LoadingPlaceholder, Tooltip } from '@grafana/ui';
import Text from 'components/Text/Text';
@ -8,8 +8,8 @@ interface IntegrationTemplateBlockProps {
label: string;
labelTooltip?: string;
renderInput: () => React.ReactNode;
showClose?: boolean;
showHelp?: boolean;
isLoading?: boolean;
onEdit: (templateName) => void;
onRemove?: () => void;
@ -20,11 +20,11 @@ const IntegrationTemplateBlock: React.FC<IntegrationTemplateBlockProps> = ({
label,
labelTooltip,
renderInput,
showClose,
showHelp,
onEdit,
onHelp,
onRemove,
isLoading,
}) => {
let inlineLabelProps = { labelTooltip };
if (!labelTooltip) {
@ -32,19 +32,26 @@ const IntegrationTemplateBlock: React.FC<IntegrationTemplateBlockProps> = ({
}
return (
<HorizontalGroup align={'flex-start'}>
<HorizontalGroup align={'flex-start'} spacing={'xs'}>
<InlineLabel width={20} {...inlineLabelProps}>
{label}
</InlineLabel>
{renderInput()}
<Button variant={'secondary'} icon={'edit'} size={'md'} onClick={onEdit} />
{showClose && <Button variant={'secondary'} icon={'times'} size={'md'} onClick={onRemove} />}
<Tooltip content={'Edit'}>
<Button variant={'secondary'} icon={'edit'} tooltip="Edit" size={'md'} onClick={onEdit} />
</Tooltip>
<Tooltip content={'Reset Template to default'}>
<Button variant={'secondary'} icon={'times'} size={'md'} onClick={onRemove} />
</Tooltip>
{showHelp && (
<Button variant="secondary" size="md" onClick={onHelp}>
<Text type="link">Help</Text>
<Icon name="angle-down" size="sm" />
</Button>
)}
{isLoading && <LoadingPlaceholder text="Loading..." />}
</HorizontalGroup>
);
};

View file

@ -1,11 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import { ButtonCascader, CascaderOption, VerticalGroup } from '@grafana/ui';
import { ConfirmModal, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification } from 'utils';
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL, MONACO_OPTIONS } from './Integration2.config';
import IntegrationHelper from './Integration2.helper';
@ -17,31 +20,36 @@ const cx = cn.bind(styles);
interface IntegrationTemplateListProps {
templates: AlertTemplatesDTO[];
getTemplatesList(): CascaderOption[];
alertReceiveChannelId: AlertReceiveChannel['id'];
openEditTemplateModal: (templateName: string | string[]) => void;
}
const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
templates,
openEditTemplateModal,
getTemplatesList,
alertReceiveChannelId,
}) => {
const isAutoAcknOrSourceLinkChanged =
!templates['acknowledge_condition_template_is_default'] || !templates['source_link_template'];
const isPhoneOrSMSChanged =
!templates['sms_title_template_is_default'] || !templates['phone_call_title_template_is_default'];
const isSlackChanged =
!templates['slack_title_template_is_default'] ||
!templates['slack_message_template_is_default'] ||
!templates['slack_image_url_template_is_default'];
const isTelegramChanged =
!templates['telegram_title_template_is_default'] ||
!templates['telegram_message_template_is_default'] ||
!templates['telegram_image_url_template_is_default'];
const isEmailOrMessageChanged = !templates['email_title_template_is_default'] || !templates['email_message_template'];
const { alertReceiveChannelStore } = useStore();
const [isRestoringTemplate, setIsRestoringTemplate] = useState<boolean>(false);
const [templateRestoreName, setTemplateRestoreName] = useState<string>(undefined);
const [showConfirmModal, setShowConfirmModal] = useState(false);
return (
<div className={cx('integration__templates')}>
{showConfirmModal && (
<ConfirmModal
isOpen={true}
title={undefined}
confirmText={'Reset'}
dismissText="Cancel"
body={'Are you sure you want to reset Slack Title template to default state?'}
description={undefined}
confirmationText={undefined}
onConfirm={() => onResetTemplate(templateRestoreName)}
onDismiss={() => onDismiss()}
/>
)}
<IntegrationBlockItem>
<Text type="secondary">
Templates are used to interpret alert from monitoring. Reduce noise, customize visualization
@ -51,10 +59,12 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
<IntegrationBlockItem>
<VerticalGroup>
<IntegrationTemplateBlock
onRemove={() => onShowConfirmModal('grouping_id_template')}
isLoading={isRestoringTemplate && templateRestoreName === 'grouping_id_template'}
label={'Grouping'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['grouping_id_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
@ -64,15 +74,16 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
/>
</div>
)}
showHelp
onEdit={() => openEditTemplateModal('grouping_id_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'resolve_condition_template'}
onRemove={() => onShowConfirmModal('resolve_condition_template')}
label={'Auto resolve'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['resolve_condition_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
@ -92,10 +103,12 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
<Text type={'primary'}>Web</Text>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'web_title_template'}
onRemove={() => onShowConfirmModal('web_title_template')}
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['web_title_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
@ -109,10 +122,12 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'web_message_template'}
onRemove={() => onShowConfirmModal('web_message_template')}
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['web_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
@ -126,10 +141,12 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'web_image_url_template'}
onRemove={() => onShowConfirmModal('web_image_url_template')}
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['web_image_url_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
@ -144,305 +161,294 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
</VerticalGroup>
</IntegrationBlockItem>
{isAutoAcknOrSourceLinkChanged && (
<IntegrationBlockItem>
<VerticalGroup>
{!templates['acknowledge_condition_template_is_default'] && (
<IntegrationTemplateBlock
label={'Auto acknowledge'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(
templates['acknowledge_condition_template'] || '',
false
)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('acknowledge_condition_template')}
showHelp
/>
<IntegrationBlockItem>
<VerticalGroup>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'acknowledge_condition_template'}
onRemove={() => onShowConfirmModal('acknowledge_condition_template')}
label={'Auto acknowledge'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(
templates['acknowledge_condition_template'] || '',
false
)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('acknowledge_condition_template')}
/>
{!templates['source_link_template_is_default'] && (
<IntegrationTemplateBlock
label={'Source Link'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['source_link_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('source_link_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'source_link_template'}
onRemove={() => onShowConfirmModal('source_link_template')}
label={'Source Link'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['source_link_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
</VerticalGroup>
</IntegrationBlockItem>
)}
{isPhoneOrSMSChanged && (
<IntegrationBlockItem>
<VerticalGroup>
{!templates['phone_call_title_template_is_default'] && (
<IntegrationTemplateBlock
label={'Phone Call'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['phone_call_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('phone_call_title_template')}
showHelp
/>
)}
{!templates['sms_title_template_is_default'] && (
<IntegrationTemplateBlock
label={'SMS'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['sms_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('sms_title_template')}
/>
)}
</VerticalGroup>
</IntegrationBlockItem>
)}
{isSlackChanged && (
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Slack</Text>
{!templates['slack_title_template_is_default'] && (
<IntegrationTemplateBlock
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['slack_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_title_template')}
/>
)}
{!templates['slack_message_template_is_default'] && (
<IntegrationTemplateBlock
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['slack_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_message_template')}
/>
)}
{!templates['slack_image_url_template_is_default'] && (
<IntegrationTemplateBlock
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['slack_image_url_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_image_url_template')}
/>
)}
</VerticalGroup>
</IntegrationBlockItem>
)}
{isTelegramChanged && (
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Telegram</Text>
{!templates['telegram_title_template_is_default'] && (
<IntegrationTemplateBlock
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['telegram_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_title_template')}
/>
)}
{!templates['telegram_message_template_is_default'] && (
<IntegrationTemplateBlock
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['telegram_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_message_template')}
/>
)}
{!templates['telegram_image_url_template_is_default'] && (
<IntegrationTemplateBlock
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(
templates['telegram_image_url_template'] || '',
false
)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_image_url_template')}
/>
)}
</VerticalGroup>
</IntegrationBlockItem>
)}
{isEmailOrMessageChanged && (
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Email</Text>
{!templates['email_title_template_is_default'] && (
<IntegrationTemplateBlock
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['email_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('email_title_template')}
/>
)}
{!templates['email_message_template_is_default'] && (
<IntegrationTemplateBlock
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['email_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('email_message_template')}
/>
)}
</VerticalGroup>
</IntegrationBlockItem>
)}
onEdit={() => openEditTemplateModal('source_link_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'secondary'}>By default alert groups rendered based on Web templates.</Text>
<Text type={'secondary'}>
Customise how they rendered in SMS, Phone Calls, Mobile App, Slack, Telegram, MS Teams{' '}
</Text>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'phone_call_title_template'}
onRemove={() => onShowConfirmModal('phone_call_title_template')}
label={'Phone Call'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['phone_call_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('phone_call_title_template')}
/>
<div className={cx('customise-button')}>
<ButtonCascader
variant="secondary"
onChange={(_key) => {
if (Object.values(_key).length > 1) {
openEditTemplateModal(Object.values(_key)[1]);
} else {
openEditTemplateModal(_key);
}
}}
options={getTemplatesList()}
icon="plus"
value={undefined}
buttonProps={{ size: 'sm' }}
>
Customise templates
</ButtonCascader>
</div>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'sms_title_template'}
onRemove={() => onShowConfirmModal('sms_title_template')}
label={'SMS'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['sms_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('sms_title_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Slack</Text>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'slack_title_template'}
onRemove={() => onShowConfirmModal('slack_title_template')}
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['slack_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_title_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'slack_message_template'}
onRemove={() => onShowConfirmModal('slack_message_template')}
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['slack_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_message_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'slack_image_url_template'}
onRemove={() => onShowConfirmModal('slack_image_url_template')}
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['slack_image_url_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('slack_image_url_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Telegram</Text>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'telegram_title_template'}
onRemove={() => onShowConfirmModal('telegram_title_template')}
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['telegram_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_title_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'telegram_message_template'}
onRemove={() => onShowConfirmModal('telegram_message_template')}
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['telegram_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_message_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'telegram_image_url_template'}
onRemove={() => onShowConfirmModal('telegram_image_url_template')}
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['telegram_image_url_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('telegram_image_url_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Email</Text>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'email_title_template'}
onRemove={() => onShowConfirmModal('email_title_template')}
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['email_title_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('email_title_template')}
/>
<IntegrationTemplateBlock
isLoading={isRestoringTemplate && templateRestoreName === 'email_message_template'}
onRemove={() => onShowConfirmModal('email_message_template')}
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoEditor
value={IntegrationHelper.getFilteredTemplate(templates['email_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('email_message_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
</div>
);
function onShowConfirmModal(templateName: string) {
setTemplateRestoreName(templateName);
setShowConfirmModal(true);
}
function onDismiss() {
setTemplateRestoreName(undefined);
setShowConfirmModal(false);
}
function onResetTemplate(templateName: string) {
setTemplateRestoreName(undefined);
setIsRestoringTemplate(true);
alertReceiveChannelStore
.saveTemplates(alertReceiveChannelId, { [templateName]: '' })
.then(() => {
openNotification('The Alert template has been updated');
})
.catch((err) => {
if (err.response?.data?.length > 0) {
openErrorNotification(err.response.data);
} else {
openErrorNotification(err.message);
}
})
.finally(() => {
setIsRestoringTemplate(false);
setShowConfirmModal(false);
});
}
};
export default IntegrationTemplateList;

View file

@ -23,8 +23,8 @@ import IntegrationForm2 from 'containers/IntegrationForm/IntegrationForm2';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartGreenIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import IntegrationHelper from 'pages/integration_2/Integration2.helper';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -36,6 +36,7 @@ import styles from './Integrations2.module.scss';
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
const ITEMS_PER_PAGE = 15;
const MAX_LINE_LENGTH = 40;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
@ -116,7 +117,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
const columns = [
{
width: '25%',
width: '35%',
title: 'Name',
key: 'name',
render: this.renderName,
@ -129,7 +130,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '25%',
width: '20%',
title: 'Datasource',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
@ -147,7 +148,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item, alertReceiveChannelStore, heartbeatStore),
},
{
width: '20%',
width: '15%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
@ -229,7 +230,14 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
return (
<PluginLink query={{ page: 'integrations_2', id: item.id }}>
<Text type="link" size="medium">
<Emoji className={cx('title')} text={item.verbal_name} />
<Emoji
className={cx('title')}
text={
item.verbal_name.length > MAX_LINE_LENGTH
? item.verbal_name.substring(0, MAX_LINE_LENGTH) + '...'
: item.verbal_name
}
/>
</Text>
</PluginLink>
);
@ -292,13 +300,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
const heartbeatStatus = Boolean(heartbeat?.status);
return (
<div>
{alertReceiveChannel.is_available_for_integration_heartbeat && (
{alertReceiveChannel.is_available_for_integration_heartbeat && heartbeat?.last_heartbeat_time_verbal && (
<TooltipBadge
text={undefined}
className={cx('heartbeat-badge')}
borderType={heartbeat?.last_heartbeat_time_verbal ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartGreenIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${heartbeat?.last_heartbeat_time_verbal || 'never'}`}
borderType={heartbeatStatus ? 'success' : 'danger'}
customIcon={heartbeatStatus ? <HeartIcon /> : <HeartRedIcon />}
tooltipTitle={`Last heartbeat: ${heartbeat?.last_heartbeat_time_verbal}`}
tooltipContent={undefined}
/>
)}
@ -337,7 +345,16 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<IconButton tooltip="Settings" name="cog" onClick={() => this.onIntegrationEditClick(item.id)} />
</WithPermissionControlTooltip>
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<WithConfirm>
<WithConfirm
description={
<Text>
<Emoji
className={cx('title')}
text={`Are you sure you want to delete ${item.verbal_name} integration?`}
/>
</Text>
}
>
<IconButton
tooltip="Delete"
name="trash-alt"
@ -368,9 +385,9 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
applyFilters = () => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters } = this.state;
const { integrationsFilters, page } = this.state;
return alertReceiveChannelStore.updateItems(integrationsFilters);
return alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page);
};
debouncedUpdateIntegrations = debounce(this.applyFilters, FILTERS_DEBOUNCE_MS);

View file

@ -29,6 +29,7 @@ interface MaintenancePageState {
maintenanceData?: {
type?: MaintenanceType;
alert_receive_channel_id?: AlertReceiveChannel['id'];
disabled?: boolean;
};
}

View file

@ -21,6 +21,7 @@
--tag-warning: #c69b06;
--tag-primary: #299c46;
--tag-secondary: #464c54;
--tag-secondary-transparent: rgba(204, 204, 220, 0.07);
--tag-background-primary: rgba(56, 113, 220, 0.2);
--tag-border-primary: rgba(56, 113, 220, 0.2);
--tag-background-danger: rgba(242, 73, 92, 0.15);