diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba8b83a..28ad5925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 19a7917a..d3d0d91d 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -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() diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 592ccaed..628244da 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -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) diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index 26af0fe8..dea69ef7 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -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"] diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 18e694cd..8fe16747 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -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 diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 1157cc74..4a1bf05f 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -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() diff --git a/engine/config_integrations/alertmanager.py b/engine/config_integrations/alertmanager.py index e180daa8..8a2f8464 100644 --- a/engine/config_integrations/alertmanager.py +++ b/engine/config_integrations/alertmanager.py @@ -1,6 +1,6 @@ # Main enabled = True -title = "AlertManager" +title = "Alertmanager" slug = "alertmanager" short_description = "Prometheus" is_displayed_on_web = True diff --git a/engine/config_integrations/formatted_webhook.py b/engine/config_integrations/formatted_webhook.py index fb6e3061..b653e837 100644 --- a/engine/config_integrations/formatted_webhook.py +++ b/engine/config_integrations/formatted_webhook.py @@ -1,6 +1,6 @@ # Main enabled = True -title = "Formatted Webhook" +title = "Formatted webhook" slug = "formatted_webhook" short_description = None description = None diff --git a/engine/requirements.txt b/engine/requirements.txt index 1b8aacf1..aac9e7e6 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -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 diff --git a/engine/settings/base.py b/engine/settings/base.py index c7c00f39..eb9ed5d9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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) diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 7f6637cf..984042a4 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -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) => { )} - { + setExpandedList(getStartingExpandedState()); + }, [configElements]); + return (
{configElements.map((item: IntegrationCollapsibleItem | IntegrationCollapsibleItem[], idx) => { @@ -34,7 +40,7 @@ const IntegrationCollapsibleTreeView: React.FC expandOrCollapseAtPos(idx, innerIdx)} - isExpanded={!!expandedList[idx][innerIdx]} + isExpanded={expandedList[idx][innerIdx]} /> )); } @@ -44,7 +50,7 @@ const IntegrationCollapsibleTreeView: React.FC expandOrCollapseAtPos(idx)} - isExpanded={!!expandedList[idx]} + isExpanded={expandedList[idx] as boolean} /> ); })} @@ -65,6 +71,18 @@ const IntegrationCollapsibleTreeView: React.FC { if (!isUndefined(j) && index === i) { @@ -79,15 +97,22 @@ const IntegrationCollapsibleTreeView: React.FC = ({ - 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 (
- + {canHoverIcon ? ( + + ) : ( + + )}
{item.expandedView} diff --git a/grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.module.scss b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss similarity index 92% rename from grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.module.scss rename to grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss index 78805c4f..c134cce0 100644 --- a/grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.module.scss +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.module.scss @@ -2,7 +2,6 @@ position: relative; display: flex; flex-grow: 1; - margin-right: 24px; height: 25px; } diff --git a/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx new file mode 100644 index 00000000..3f3992ae --- /dev/null +++ b/grafana-plugin/src/components/IntegrationInputField/IntegrationInputField.tsx @@ -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 = ({ + isMasked = true, + value, + showEye = true, + showCopy = true, + showExternal = true, + className, +}) => { + const [isInputMasked, setIsMasked] = useState(isMasked); + + return ( +
+
{renderInputField()}
+ +
+ + {showEye && } + {showCopy && ( + + + + )} + {showExternal && } + +
+
+ ); + + function renderInputField() { + return ; + } + + function onInputReveal() { + setIsMasked(!isInputMasked); + } + + function onCopy() { + openNotification("Integration's HTTP Endpoint is copied!"); + } + + function onOpen() { + window.open(value, '_blank'); + } +}; + +export default IntegrationInputField; diff --git a/grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.tsx b/grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.tsx deleted file mode 100644 index c5bce2de..00000000 --- a/grafana-plugin/src/components/IntegrationMaskedInputField/IntegrationMaskedInputField.tsx +++ /dev/null @@ -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 = ({ value }) => { - const [isMasked, setIsMasked] = useState(true); - - return ( -
-
{renderInputField()}
- -
- - - - - - - -
-
- ); - - function renderInputField() { - return ; - } - - function onInputReveal() { - setIsMasked(!isMasked); - } - - function onCopy() { - openNotification("Integration's HTTP Endpoint is copied!"); - } - - function onOpen() { - window.open(value, '_blank'); - } -}; - -export default IntegrationMaskedInputField; diff --git a/grafana-plugin/src/components/MonacoJinja2Editor/MonacoJinja2Editor.tsx b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx similarity index 52% rename from grafana-plugin/src/components/MonacoJinja2Editor/MonacoJinja2Editor.tsx rename to grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx index 34c2477e..ce16c0d6 100644 --- a/grafana-plugin/src/components/MonacoJinja2Editor/MonacoJinja2Editor.tsx +++ b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx @@ -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 = (props) => { - const { value, onChange, disabled, data, height, monacoOptions, showLineNumbers = true, loading = false } = props; +const MonacoEditor: FC = (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 = (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 = (props) => { return ; } + const otherProps: any = {}; + if (useAutoCompleteList) { + otherProps.getSuggestions = { autoCompleteList }; + } + return ( = (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; diff --git a/grafana-plugin/src/components/MonacoJinja2Editor/jinja2.ts b/grafana-plugin/src/components/MonacoEditor/jinja2.ts similarity index 100% rename from grafana-plugin/src/components/MonacoJinja2Editor/jinja2.ts rename to grafana-plugin/src/components/MonacoEditor/jinja2.ts diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 029f4bab..3560932d 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -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; diff --git a/grafana-plugin/src/components/Tag/Tag.tsx b/grafana-plugin/src/components/Tag/Tag.tsx index 088842cb..4de3374c 100644 --- a/grafana-plugin/src/components/Tag/Tag.tsx +++ b/grafana-plugin/src/components/Tag/Tag.tsx @@ -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; @@ -15,13 +16,17 @@ interface TagProps { const cx = cn.bind(styles); const Tag: FC = (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 ( {children} diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index ba7664f0..e03ef2c8 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -80,14 +80,19 @@ const Text: TextInterface = (props) => { return ( diff --git a/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx b/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx index bff57aff..aa53a2f0 100644 --- a/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx +++ b/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx @@ -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'; diff --git a/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx b/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx index fd211342..b4e641f5 100644 --- a/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx +++ b/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx @@ -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) => { } > - { disabled={data?.is_default} error={errors['filtering_term']} > - { const { onHide, onUpdateRoute, channelFilterId, onOpenEditIntegrationTemplate, alertReceiveChannelId } = props; const store = useStore(); + const regexpBody = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term; + const [regexpTemplateBody, setRegexpTemplateBody] = useState(regexpBody); const templateJinja2Body = store.alertReceiveChannelStore.channelFilters[channelFilterId]?.filtering_term_as_jinja2; @@ -86,7 +88,7 @@ const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemplateMod
- { return ( 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(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) => { - (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 && (
@@ -96,7 +115,7 @@ const IntegrationForm2 = observer((props: IntegrationFormProps) => { {
)} - {(showNewIntegrationForm || id !== 'new') && ( - + {(showNewIntegrationForm || !showIntegrarionsListDrawer) && ( +
- How the integration works} - contentClassName={cx('collapsable-content')} - > - - The integration will generate the following: -
    -
  • Unique URL endpoint for receiving alerts
  • -
  • - Templates to interpret alerts, tailored for Grafana Alerting{' '} -
  • -
  • Grafana Alerting contact point
  • -
  • Grafana Alerting notification
  • -
- What you’ll need to do next: -
    -
  • - Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '} -
  • -
  • - Set up routes that are based on alert content, such as severity, region, and service{' '} -
  • -
  • Connect escalation chains to the routes
  • -
  • - Review templates and personalize according to your requirements -
  • -
-
-
+ {isTableView && ( + How the integration works} + contentClassName={cx('collapsable-content')} + > + + The integration will generate the following: +
    +
  • Unique URL endpoint for receiving alerts
  • +
  • + Templates to interpret alerts, tailored for Grafana Alerting{' '} +
  • +
  • Grafana Alerting contact point
  • +
  • Grafana Alerting notification
  • +
+ What you’ll need to do next: +
    +
  • + Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '} +
  • +
  • + Set up routes that are based on alert content, such as severity, region, and service{' '} +
  • +
  • Connect escalation chains to the routes
  • +
  • + Review templates and personalize according to your requirements +
  • +
+
+
+ )} {id === 'new' ? ( - ) : ( @@ -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; diff --git a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx index e481b102..539e645e 100644 --- a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx +++ b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx @@ -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(false); const [chatOps, setChatOps] = useState(undefined); @@ -45,14 +47,11 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => { const [changedTemplateBody, setChangedTemplateBody] = useState(templateBody); const [resultError, setResultError] = useState(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 ? ( @@ -188,9 +188,9 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
- {
)} - {/* {alertGroupPayload || resultError ? ( */} { error={resultError} onSaveAndFollowLink={onSaveAndFollowLink} /> - {/* ) : ( -
-
- Please select Alert group to see end result -
-
- )} */}
diff --git a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx index b0d2e5e0..597d34dd 100644 --- a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx +++ b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx @@ -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 ( - +
- +
-
@@ -102,9 +116,19 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
- - {JSON.stringify(selectedAlertPayload, null, 4)} - +
+ +
@@ -124,12 +148,16 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
-
@@ -150,30 +178,37 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
- {alertGroupsList?.length > 0 ? ( + {alertGroupsList ? ( <> - {alertGroupsList.map((alertGroup) => { - return ( -
- -
- ); - })} + {alertGroupsList?.length > 0 ? ( + <> + {alertGroupsList.map((alertGroup) => { + return ( +
+ +
+ ); + })} + + ) : ( + + + + This integration did not receive any alerts. Use custom payload example to preview + results. + +
+ } + /> + )} ) : ( - - - - This integration did not receive any alerts. Use custom payload example to preview results. - - - } - /> + )} diff --git a/grafana-plugin/src/containers/UserDisplay/UserDisplayWithAvatar.tsx b/grafana-plugin/src/containers/UserDisplay/UserDisplayWithAvatar.tsx index 1073520e..4d05b2d2 100644 --- a/grafana-plugin/src/containers/UserDisplay/UserDisplayWithAvatar.tsx +++ b/grafana-plugin/src/containers/UserDisplay/UserDisplayWithAvatar.tsx @@ -29,7 +29,7 @@ const UserDisplayWithAvatar = observer(({ id }: UserDisplayProps) => { return ( - {user.email} + {user.email} ); }); diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index c5675c28..3e0141c8 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -183,11 +183,11 @@ export const HeartIcon = (_props: IconProps) => ( diff --git a/grafana-plugin/src/models/action.ts b/grafana-plugin/src/models/action.ts index e8ada731..ea6fc747 100644 --- a/grafana-plugin/src/models/action.ts +++ b/grafana-plugin/src/models/action.ts @@ -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; diff --git a/grafana-plugin/src/models/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel.ts deleted file mode 100644 index ec451819..00000000 --- a/grafana-plugin/src/models/alert_receive_channel.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index cb41f6e5..20826934 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -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); } diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index 62c81c68..8ab3eb5f 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -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; } diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 6f547f74..82ebae8f 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -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'; diff --git a/grafana-plugin/src/models/channel_filter.ts b/grafana-plugin/src/models/channel_filter.ts index 6dc428e4..0fb6754d 100644 --- a/grafana-plugin/src/models/channel_filter.ts +++ b/grafana-plugin/src/models/channel_filter.ts @@ -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; diff --git a/grafana-plugin/src/pages/integration_2/CollapsedIntegrationRouteDisplay.tsx b/grafana-plugin/src/pages/integration_2/CollapsedIntegrationRouteDisplay.tsx index a47cfd17..4ce63f5d 100644 --- a/grafana-plugin/src/pages/integration_2/CollapsedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/pages/integration_2/CollapsedIntegrationRouteDisplay.tsx @@ -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 - - {IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)} - + {channelFilter.filtering_term && ( - {IntegrationHelper.truncateLine(channelFilter.filtering_term)} + {IntegrationHelper.truncateLine(channelFilter.filtering_term)} )} @@ -77,15 +82,24 @@ const CollapsedIntegrationRouteDisplay: React.FC Escalate to - - - {escalationChain?.name} - - + {escalationChain?.name && ( + + + {escalationChain?.name} + + + )} + {!escalationChain?.name && ( + + )} diff --git a/grafana-plugin/src/pages/integration_2/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/pages/integration_2/ExpandedIntegrationRouteDisplay.tsx index c38bbe20..cdd857dd 100644 --- a/grafana-plugin/src/pages/integration_2/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/pages/integration_2/ExpandedIntegrationRouteDisplay.tsx @@ -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 - - {IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)} - + - {routeIndex !== channelFiltersTotal.length - 1 && ( - - - - Routing Template - -
- -
- -
-
- )} + + +
+ {routeIndex !== channelFiltersTotal.length - 1 && ( @@ -177,23 +179,28 @@ const ExpandedIntegrationRouteDisplay: React.FC + + {channelFilter.escalation_chain && ( + + )} {isEscalationCollapsed && ( @@ -242,7 +249,7 @@ const ExpandedIntegrationRouteDisplay: React.FC = ({ const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters); return ( - + {routeIndex > 0 && !channelFilter.is_default && ( - - - - ( -
-
this.openIntegrationSettings(id, closeMenu)} - > - Integration Settings -
- -
this.openHearbeat(id, closeMenu)}> - Hearbeat -
- -
this.openStartMaintenance(id, closeMenu)} - > - Start Maintenance -
- -
- - -
- - Are you sure you want to delete {' '} - integration? - - } - > -
this.deleteIntegration(id, closeMenu)}> -
{ - // work-around to prevent 2 modals showing (withContextMenu and ConfirmModal) - const contextMenuEl = - document.querySelector('#integration-menu-options'); - if (contextMenuEl) { - contextMenuEl.style.display = 'none'; - } - }} - > - Stop Maintenance -
-
-
-
-
-
- )} - > - {({ openMenu }) => } - -
+
@@ -231,16 +196,23 @@ class Integration2 extends React.Component {alertReceiveChannel.description_short} )} + {alertReceiveChannelCounter && ( - + + + )} Type: - + - - {integration?.display_name} - + {integration?.display_name} @@ -295,77 +265,87 @@ class Integration2 extends React.Component expandedView: , }, { + customIcon: 'layer-group', isExpanded: false, - isCollapsible: true, - collapsedView: ( + isCollapsible: false, + canHoverIcon: false, + expandedView: ( - +
+ Templates - - Grouping: - - {IntegrationHelper.truncateLine(templates['grouping_id_template'] || '')} - - +
+ + + Grouping: + + {IntegrationHelper.truncateLine(templates['grouping_id_template'] || '')} + + - - Autoresolve: - - {IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')} - - + + Autoresolve: + + {IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')} + + - - Visualisation: - Multiple - - + + Visualisation: + Multiple + + + +
+
+
+
} content={null} /> ), - expandedView: ( - - - - Templates - - -
- } - content={ - - } - /> - ), + collapsedView: undefined, }, { - customIcon: 'plus', + customIcon: 'code-branch', isCollapsible: false, collapsedView: null, + canHoverIcon: false, expandedView: (
- Routes - - - + + Routes + + + + + + {this.state.isAddingRoute && } +
), @@ -374,30 +354,25 @@ class Integration2 extends React.Component ]} /> - this.setState({ isDemoModalOpen: false })} - /> {isEditTemplateModalOpen && ( { 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 ); } - 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 const templates = alertReceiveChannelStore.templates[id]; const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id]; - return channelFilterIds.map((channelFilterId: ChannelFilter['id'], routeIndex: number) => ({ - isCollapsible: true, - isExpanded: false, - collapsedView: ( - - ), - expandedView: ( - - ), - })); + 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: ( + + ), + expandedView: ( + + ), + } as IntegrationCollapsibleItem) + ); }; renderHearbeat = (alertReceiveChannel: AlertReceiveChannel) => { @@ -473,13 +485,20 @@ class Integration2 extends React.Component const heartbeatStatus = Boolean(heartbeat?.status); + if ( + !alertReceiveChannel.is_available_for_integration_heartbeat || + alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal === null + ) { + return null; + } + return ( : } - tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal || 'never'}`} + borderType={heartbeatStatus ? 'success' : 'danger'} + customIcon={heartbeatStatus ? : } + tooltipTitle={`Last heartbeat: ${alertReceiveChannel.heartbeat?.last_heartbeat_time_verbal}`} tooltipContent={undefined} /> ); @@ -507,38 +526,9 @@ class Integration2 extends React.Component 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 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 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 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 { const { alertReceiveChannelStore } = useStore(); + const [demoPayload, setDemoPayload] = useState( + JSON.stringify(alertReceiveChannel.demo_alert_payload, null, '\t') + ); + let onPayloadChangeDebounced = debounce(100, onPayloadChange); return ( Alert Payload - + + A demo alert will be generated. You can find it on the Alert Groups page + + } + placement={'top-start'} + > - {getDemoAlertJSON()} +
+ +
+ + + ( +
+
openIntegrationSettings()}> + Integration Settings +
+ +
setIsHearbeatFormOpen(true)}> + Hearbeat +
+ + {!alertReceiveChannel.maintenance_till && ( + +
+ Start Maintenance +
+
+ )} + + {alertReceiveChannel.maintenance_till && ( + +
+
{ + setConfirmModal({ + isOpen: true, + confirmText: 'Stop', + dismissText: 'Cancel', + onConfirm: onStopMaintenance, + title: ( + <> + Are you sure you want to stop the maintenance for{' '} + ? + + ), + }); + }} + > + Stop Maintenance +
+
+
+ )} + +
+ + +
+
{ + setConfirmModal({ + isOpen: true, + title: ( + <> + Are you sure you want to delete {' '} + integration? + + ), + body: <>This action cannot be undone., + onConfirm: deleteIntegration, + dismissText: 'Cancel', + confirmText: 'Delete', + }); + }} + > + + + + Delete Integration + + +
+
+
+
+ )} + > + {({ openMenu }) => } + +
+ + ); + + 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={
- + HTTP Endpoint - + - + How to connect @@ -772,15 +993,13 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id /> ); - function openHowToConnect() {} - function renderContent() { return (
{!hasAlerts && ( - + No alerts yet; try to send a demo alert )} diff --git a/grafana-plugin/src/pages/integration_2/Integration2HeartbeatForm.module.scss b/grafana-plugin/src/pages/integration_2/Integration2HeartbeatForm.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/grafana-plugin/src/pages/integration_2/Integration2HeartbeatForm.tsx b/grafana-plugin/src/pages/integration_2/Integration2HeartbeatForm.tsx new file mode 100644 index 00000000..d38f9150 --- /dev/null +++ b/grafana-plugin/src/pages/integration_2/Integration2HeartbeatForm.tsx @@ -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(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 ( + + + + Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may + trigger false alarms. + + + +
+ + +