Main Grouping&Templating PR fro all frontend changes (#1731)

# What this PR does
Main Grouping&Templating PR fro all frontend changes:
Includes:

1. Integration table view
2. Integration form using Drawer component
3. Integration landing page with routes/escalation chains
4. Templates
5. Groupong

## Which issue(s) this PR fixes
https://github.com/grafana/oncall/issues/1620
https://github.com/grafana/oncall/issues/1621

---------

Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
Co-authored-by: Ildar Iskhakov <ildar.iskhakov@grafana.com>
This commit is contained in:
Yulia Shanyrova 2023-05-03 16:51:45 +02:00 committed by GitHub
parent d198b932c1
commit b10b589f72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 4878 additions and 5191 deletions

View file

@ -4,6 +4,8 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter
# from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
@ -31,6 +33,12 @@ from common.api_helpers.mixins import (
from common.exceptions import MaintenanceCouldNotBeStartedError, TeamCanNotBeChangedError, UnableToSendDemoAlert
from common.insight_log import EntityEvent, write_resource_insight_log
# class AlertReceiveChannelPagination(PageNumberPagination):
# page_size = 25
# page_query_param = "page"
# page_size_query_param = "perpage"
# max_page_size = 50
class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
maintenance_mode = filters.MultipleChoiceFilter(
@ -81,6 +89,7 @@ class AlertReceiveChannelView(
search_fields = ("verbal_name",)
filterset_class = AlertReceiveChannelFilter
# pagination_class = AlertReceiveChannelPagination
rbac_permissions = {
"metadata": [RBACPermission.Permissions.INTEGRATIONS_READ],

View file

@ -1,4 +1,4 @@
const esModules = ['react-colorful', 'uuid', 'ol'].join('|');
const esModules = ['@grafana', '@grafana/ui', 'ol', 'd3-interpolate', 'd3-color', 'react-colorful', 'uuid'].join('|');
module.exports = {
testEnvironment: 'jsdom',

View file

@ -111,7 +111,7 @@
"@grafana/faro-web-sdk": "^1.0.0-beta4",
"@grafana/faro-web-tracing": "^1.0.0-beta4",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/ui": "^9.2.4",
"@grafana/ui": "^9.4.7",
"@opentelemetry/api": "^1.3.0",
"array-move": "^4.0.0",
"change-case": "^4.1.1",

View file

@ -3,6 +3,143 @@ export interface Template {
group: string;
}
export interface TemplateForEdit {
displayName: string;
name: string;
description?: string;
additionalData?: {
chatOpsName?: string;
data?: string;
additionalDescription?: string;
};
isRoute?: boolean;
}
export const templateForEdit: { [id: string]: TemplateForEdit } = {
web_title_template: {
displayName: 'Web title',
name: 'web_title_template',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
web_message_template: {
displayName: 'Web message',
name: 'web_message_template',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
slack_title_template: {
name: 'slack_title_template',
displayName: 'Slack title',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
sms_title_template: {
name: 'sms_title_template',
displayName: 'Sms title',
description: '',
},
phone_call_title_template: {
name: 'phone_call_title_template',
displayName: 'Phone call title',
description: '',
},
email_title_template: {
name: 'email_title_template',
displayName: 'Email title',
description: '',
},
telegram_title_template: {
name: 'telegram_title_template',
displayName: 'Telegram title',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_message_template: {
name: 'slack_message_template',
displayName: 'Slack message',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
email_message_template: {
name: 'email_message_template',
displayName: 'Email message',
description: '',
},
telegram_message_template: {
name: 'telegram_message_template',
displayName: 'Telegram message',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_image_url_template: {
name: 'slack_image_url_template',
displayName: 'Slack image url',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
web_image_url_template: {
name: 'web_image_url_template',
displayName: 'Web image url',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
telegram_image_url_template: {
name: 'telegram_image_url_template',
displayName: 'Telegram image url',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
grouping_id_template: {
name: 'grouping_id_template',
displayName: 'Grouping',
description:
'Reduce noise, minimize duplication with Alert Grouping, based on time, alert content, and even multiple features at the same time. Check the cheasheet to customize your template.',
},
acknowledge_condition_template: {
name: 'acknowledge_condition_template',
displayName: 'Acknowledge condition',
description: '',
},
resolve_condition_template: {
name: 'resolve_condition_template',
displayName: 'Resolve condition',
description:
'When monitoring systems return to normal, they can send "resolve" alerts. OnCall can use these signals to resolve alert groups accordingly.',
},
source_link_template: {
name: 'source_link_template',
displayName: 'Source link',
description: '',
},
routing: {
name: 'routing',
displayName: 'Routing',
description:
'Routes direct alerts to different escalation chains based on the content, such as severity or region.',
additionalData: {
additionalDescription: 'For an alert to be directed to this route, the template must evaluate to True.',
data: 'Selected Alert will be directed to this route',
},
isRoute: true,
},
};
export const templatesToRender: Template[] = [
{
name: 'web_title_template',

View file

@ -0,0 +1,154 @@
export interface CheatSheetItem {
name: string;
listItems?: Array<{
listItemName?: string;
codeExample?: string;
}>;
}
export interface CheatSheetInterface {
name: string;
description: string;
fields: CheatSheetItem[];
}
export const groupingTemplateCheatSheet: CheatSheetInterface = {
name: 'Grouping template cheatsheet',
description: 'Jinja2 is used for templating ( docs). ',
fields: [
{
name: 'Additional variables and functions',
listItems: [
{ listItemName: 'time(), datetimeformat, iso8601_to_time' },
{ listItemName: 'to_pretty_json' },
{ listItemName: 'regex_replace, regex_match' },
],
},
{
name: 'Examples',
listItems: [
{ listItemName: 'group every hour', codeExample: '{{ time() | datetimeformat("%d-%m-%Y %H") }}' },
{ listItemName: 'group every X hours', codeExample: '{{ every_hour(5) }}' },
{ listItemName: 'group alerts every microsecond (every 0.000001 second)', codeExample: '{{ time() }}' },
{ listItemName: 'group based on the specific field', codeExample: '{{ payload.uuid }}' },
{ listItemName: 'group based on multiple fields', codeExample: '{{ payload.uuid }} \n {{ payload.region }}' },
{
listItemName: 'group alerts with the same uuid, create new group every hour',
codeExample: '{{ payload.uuid }} \n {{ time() | datetimeformat("%d-%m-%Y %H") }}',
},
],
},
],
};
export const webTitleTemplateCheatSheet: CheatSheetInterface = {
name: 'Web title template cheatsheet',
description: 'Jinja2 is used for templating (docs). \n Markdown is used for markup',
fields: [
{
name: 'Markdown refresher',
listItems: [
{ codeExample: '**bold**, _italic_, >quote, `code`, ```multiline code```, [``](url), - bullet list' },
],
},
{
name: 'Jinja2 refresher ',
listItems: [
{ listItemName: ' {{ payload.labels.foo }} - extract field value' },
{
listItemName: 'Conditions',
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
},
{ listItemName: 'Booleans', codeExample: '{{ payload.status == “resolved” }}' },
{ listItemName: 'Loops', codeExample: '{% for label in labels %} \n {{ label.title }} \n {% endfor %}' },
],
},
{
name: 'Additional jinja2 variables',
listItems: [
{ listItemName: 'payload - payload of last alert in the group' },
{ listItemName: 'web_title, web_mesage, web_image_url - templates from Web' },
{ listItemName: 'payload, grafana_oncall_link, grafana_oncall_incident_id, integration_name, source_link' },
{ listItemName: 'time(), datetimeformat, iso8601_to_time' },
{ listItemName: 'to_pretty_json' },
{ listItemName: 'regex_replace, regex_match' },
],
},
{
name: 'Examples',
listItems: [
{
listItemName: 'Show status if exists',
codeExample: '{%- if "status" in payload %} \n **Status**: {{ payload.status }} \n {% endif -%}',
},
{
listItemName: 'Show field value or “N/A” is not exist',
codeExample: '{{ payload.labels.foo | default(“N/A”) }}',
},
{
listItemName: 'Iterate over labels dictionary',
codeExample:
'**Labels:** \n {% for k, v in payload["labels"].items() %} \n *{{ k }}*: {{ v }} \n {% endfor %} ',
},
],
},
],
};
export const slackMessageTemplateCheatSheet: CheatSheetInterface = {
name: 'Slack message template cheatsheet',
description: 'Jinja2 is used for templating (docs). \n Markdown is used for markup',
fields: [
{
name: 'Slack Markdown refresher',
listItems: [
{ listItemName: '**bold**, _italic_, >quote, `code`, ```multiline code```, <slug|url> - bullet list' },
],
},
{
name: 'Jinja2 refresher ',
listItems: [
{ listItemName: ' {{ payload.labels.foo }} - extract field value' },
{
listItemName: 'Conditions',
codeExample: '{%- if "status" in payload %} \n {{ payload.status }} \n {% endif -%}',
},
{ listItemName: 'Booleans', codeExample: '{{ payload.status == “resolved” }}' },
{ listItemName: 'Loops', codeExample: '{% for label in labels %} \n {{ label.title }} \n {% endfor %}' },
],
},
{
name: 'Additional jinja2 variables',
listItems: [
{ listItemName: 'payload - payload of last alert in the group' },
{ listItemName: 'web_title, web_mesage, web_image_url - templates from Web' },
{ listItemName: 'payload, grafana_oncall_link, grafana_oncall_incident_id, integration_name, source_link' },
{ listItemName: 'time(), datetimeformat, iso8601_to_time' },
{ listItemName: 'to_pretty_json' },
{ listItemName: 'regex_replace, regex_match' },
],
},
{
name: 'Examples',
listItems: [
{
listItemName: 'Examples Convert Web template in Classic Markdown to Slack markdown',
codeExample: '{{ web_message \n| replace("**", "*") \n| regex_replace("/((.*))[(.*)]/", "<$2|$1>") }}',
},
{
listItemName: 'Show status if exists',
codeExample: '{%- if "status" in payload %} \n **Status**: {{ payload.status }} \n {% endif -%}',
},
{
listItemName: 'Show field value or “N/A” is not exist',
codeExample: '{{ payload.labels.foo | default(“N/A”) }}',
},
{
listItemName: 'Iterate over labels dictionary',
codeExample:
'**Labels:** \n {% for k, v in payload["labels"].items() %} \n *{{ k }}*: {{ v }} \n {% endfor %} ',
},
],
},
],
};

View file

@ -0,0 +1,14 @@
.cheatsheet-container {
width: 40%;
height: 100%;
padding: 16px;
}
.cheatsheet-item {
margin-bottom: 24px;
}
.cheatsheet-item-small {
margin-bottom: 16px;
width: 100%;
}

View file

@ -0,0 +1,83 @@
import React from 'react';
import { HorizontalGroup, IconButton, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import Block from 'components/GBlock/Block';
import Text from 'components/Text/Text';
import { openNotification } from 'utils';
import { CheatSheetInterface, CheatSheetItem } from './CheatSheet.config';
import styles from './CheatSheet.module.css';
interface CheatSheetProps {
cheatSheetData: CheatSheetInterface;
onClose: () => void;
}
const cx = cn.bind(styles);
const CheatSheet = (props: CheatSheetProps) => {
const { cheatSheetData, onClose } = props;
return (
<div className={cx('cheatsheet-container')}>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text.Title level={3}>{cheatSheetData.name}</Text.Title>
<IconButton name="times" onClick={onClose} />
</HorizontalGroup>
<Text type="secondary">{cheatSheetData.description}</Text>
<div>
{cheatSheetData.fields?.map((field: CheatSheetItem) => {
return (
<div key={field.name} className={cx('cheatsheet-item')}>
<CheatSheetListItem field={field} />
</div>
);
})}
</div>
</VerticalGroup>
</div>
);
};
interface CheatSheetListItemProps {
field: CheatSheetItem;
}
const CheatSheetListItem = (props: CheatSheetListItemProps) => {
const { field } = props;
return (
<>
<Text.Title level={4}>{field.name}</Text.Title>
{field.listItems?.map((item, key) => {
return (
<div key={key}>
<VerticalGroup spacing="md">
{item.listItemName && (
<li style={{ margin: '0 0 0 4px' }}>
<Text>{item.listItemName}</Text>
</li>
)}
{item.codeExample && (
<div className={cx('cheatsheet-item-small')}>
<Block bordered fullWidth withBackground>
<HorizontalGroup justify="space-between">
<Text type="link">{item.codeExample}</Text>
<CopyToClipboard text={item.codeExample} onCopy={() => openNotification('Example copied')}>
<IconButton name="copy" />
</CopyToClipboard>
</HorizontalGroup>
</Block>
</div>
)}
</VerticalGroup>
</div>
);
})}
</>
);
};
export default CheatSheet;

View file

@ -0,0 +1,35 @@
.element {
font-size: 12px;
line-height: 16px;
padding: 3px 4px;
&--link,
&--warning,
&--success {
border-radius: 2px;
}
&--primary {
background: var(--tag-background-primary);
border: 1px solid var(--tag-border-primary);
color: var(--tag-text-primary);
}
&--warning {
background: var(--tag-background-warning);
border: 1px solid var(--tag-border-warning);
color: var(--tag-text-warning);
}
&--success {
background: var(--tag-background-success);
border: 1px solid var(--tag-border-success);
color: var(--tag-text-success);
}
&--padding {
padding: 3px 10px;
}
}
.tooltip {
width: auto;
}

View file

@ -0,0 +1,55 @@
import React, { FC } from 'react';
import { Icon, Tooltip, IconName, VerticalGroup, HorizontalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Text, { TextType } from 'components/Text/Text';
import styles from './CounterBadge.module.scss';
interface CounterBadgeProps {
borderType: Partial<TextType>;
count: number | string;
tooltipTitle: string;
tooltipContent: React.ReactNode;
icon?: string;
addPadding?: boolean;
onHover?: () => void;
}
const cx = cn.bind(styles);
const CounterBadge: FC<CounterBadgeProps> = (props) => {
const { borderType, count, tooltipTitle, tooltipContent, onHover, addPadding, icon } = props;
return (
<Tooltip
placement="bottom-start"
interactive
content={
<div className={cx('tooltip')}>
<VerticalGroup spacing="xs">
<Text type="primary">{tooltipTitle}</Text>
{tooltipContent && <Text type="secondary">{tooltipContent}</Text>}
</VerticalGroup>
</div>
}
>
<div
className={cx('root', 'element', { [`element--${borderType}`]: true }, { 'element--padding': addPadding })}
onMouseEnter={onHover}
>
<HorizontalGroup spacing="xs">
{icon && (
<Icon className={cx('element__icon', { [`element__icon--${borderType}`]: true })} name={icon as IconName} />
)}
<Text className={cx('element__text', { [`element__text--${borderType}`]: true })}>{count}</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
export default CounterBadge;

View file

@ -29,13 +29,17 @@ const Block: FC<BlockProps> = (props) => {
return (
<div
className={cx('root', className, {
root_bordered: bordered,
root_shadowed: shadowed,
'root--fullWidth': fullWidth,
'root--withBackground': withBackground,
'root--hover': hover,
})}
className={cx(
'root',
{
root_bordered: bordered,
root_shadowed: shadowed,
'root--fullWidth': fullWidth,
'root--withBackGround': withBackground,
'root--hover': hover,
},
className
)}
style={style}
{...rest}
>

View file

@ -0,0 +1,48 @@
.integrationTree__container {
margin-left: 32px;
position: relative;
&:before {
content: '';
position: absolute;
height: calc(100% - 10px);
border: var(--border-weak);
margin-top: 0px;
margin-left: -20px;
}
}
.integrationTree__element {
visibility: hidden;
overflow-y: hidden;
height: 0;
&--visible {
visibility: visible;
height: auto;
}
}
.integrationTree__group {
position: relative;
margin-bottom: 12px;
}
.integrationTree__icon {
position: absolute;
top: 0px;
transform: translateY(50%);
left: -30px;
color: var(--always-gray);
width: 25px;
height: 32px;
text-align: center;
background-color: var(--primary-background) !important;
border: 1px solid var(--primary-background);
z-index: 100;
border-radius: 4px;
padding: 0px;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { IconButton, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
import { isArray, isUndefined } from 'lodash-es';
import styles from './IntegrationCollapsibleTreeView.module.scss';
const cx = cn.bind(styles);
export interface IntegrationCollapsibleItem {
customIcon?: IconName;
expandedView: React.ReactNode;
collapsedView: React.ReactNode;
isCollapsible: boolean;
}
interface IntegrationCollapsibleTreeViewProps {
configElements: Array<IntegrationCollapsibleItem | IntegrationCollapsibleItem[]>;
}
const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewProps> = (props) => {
const { configElements } = props;
const [expandedList, setExpandedList] = useState(getStartingExpandedState());
return (
<div className={cx('integrationTree__container')}>
{configElements.map((item: IntegrationCollapsibleItem | IntegrationCollapsibleItem[], idx) => {
if (isArray(item)) {
return item.map((it, innerIdx) => (
<IntegrationCollapsibleTreeItem
item={it}
key={`${idx}-${innerIdx}`}
onClick={() => expandOrCollapseAtPos(idx, innerIdx)}
isExpanded={!!expandedList[idx][innerIdx]}
/>
));
}
return (
<IntegrationCollapsibleTreeItem
item={item}
key={idx}
onClick={() => expandOrCollapseAtPos(idx)}
isExpanded={!!expandedList[idx]}
/>
);
})}
</div>
);
function getStartingExpandedState(): Array<boolean | boolean[]> {
const expandedArrayValues = new Array<boolean | boolean[]>(configElements.length);
configElements.forEach((elem, index) => {
expandedArrayValues[index] = Array.isArray(elem) ? new Array(elem.length).fill(true) : true;
});
return expandedArrayValues;
}
function expandOrCollapseAtPos(i: number, j: number = undefined) {
setExpandedList(
expandedList.map((elem, index) => {
if (!isUndefined(j) && index === i) {
return (elem as boolean[]).map((innerElem: boolean, jIndex: number) =>
jIndex === j ? !innerElem : innerElem
);
}
return index === i ? !elem : elem;
})
);
}
};
const IntegrationCollapsibleTreeItem: React.FC<{ item: IntegrationCollapsibleItem; isExpanded: boolean; onClick }> = ({
item,
isExpanded,
onClick,
}) => {
return (
<div className={cx('integrationTree__group')}>
<div className={cx('integrationTree__icon')}>
<IconButton name={getIconName()} onClick={!item.isCollapsible ? undefined : onClick} size="lg" />
</div>
<div className={cx('integrationTree__element', { 'integrationTree__element--visible': isExpanded })}>
{item.expandedView}
</div>
<div className={cx('integrationTree__element', { 'integrationTree__element--visible': !isExpanded })}>
{item.collapsedView}
</div>
</div>
);
function getIconName(): IconName {
if (item.customIcon) {
return item.customIcon;
}
return isExpanded ? 'angle-down' : 'angle-right';
}
};
export default IntegrationCollapsibleTreeView;

View file

@ -0,0 +1,24 @@
.root {
position: relative;
display: flex;
flex-grow: 1;
margin-right: 24px;
height: 25px;
}
.icons {
position: absolute;
right: 8px;
top: 6px;
z-index: 10;
}
.input-container {
width: 100%;
input {
height: 25px;
padding-right: 78px;
text-overflow: ellipsis;
}
}

View file

@ -0,0 +1,53 @@
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

@ -11,9 +11,12 @@ declare const monaco: any;
interface MonacoJinja2EditorProps {
value: string;
disabled?: boolean;
height?: string;
data: any;
onChange: (value: string) => void;
loading: boolean;
showLineNumbers?: boolean;
onChange?: (value: string) => void;
loading?: boolean;
monacoOptions?: any;
}
const PREDEFINED_TERMS = [
@ -25,7 +28,7 @@ const PREDEFINED_TERMS = [
];
const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
const { value, onChange, disabled, data, loading } = props;
const { value, onChange, disabled, data, height, monacoOptions, showLineNumbers = true, loading = false } = props;
const autoCompleteList = useCallback(
() =>
@ -39,7 +42,7 @@ const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
const handleMount = useCallback((editor) => {
editor.onDidChangeModelContent(() => {
onChange(editor.getValue());
onChange?.(editor.getValue());
});
editor.focus();
@ -58,13 +61,14 @@ const MonacoJinja2Editor: FC<MonacoJinja2EditorProps> = (props) => {
return (
<CodeEditor
monacoOptions={monacoOptions}
showMiniMap={false}
readOnly={disabled}
showLineNumbers
showLineNumbers={showLineNumbers}
value={value}
language="jinja2"
width="100%"
height="130px"
height={height ? `${height}` : `130px`}
onEditorDidMount={handleMount}
getSuggestions={autoCompleteList}
/>

View file

@ -26,6 +26,7 @@ import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhoo
import { ScheduleStore } from 'models/schedule/schedule';
import { WaitDelay } from 'models/wait_delay';
import { SelectOption } from 'state/types';
import { getVar } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
import DragHandle from './DragHandle';
@ -38,13 +39,14 @@ const cx = cn.bind(styles);
export interface EscalationPolicyProps {
data: EscalationPolicyType;
waitDelays?: any[];
isDisabled?: boolean;
numMinutesInWindowOptions: SelectOption[];
channels?: any[];
onChange: (id: EscalationPolicyType['id'], value: Partial<EscalationPolicyType>) => void;
onDelete: (data: EscalationPolicyType) => void;
escalationChoices: any[];
number: number;
color: string;
backgroundColor: string;
isSlackInstalled: boolean;
teamStore: GrafanaTeamStore;
outgoingWebhookStore: OutgoingWebhookStore;
@ -54,7 +56,7 @@ export interface EscalationPolicyProps {
export class EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
render() {
const { data, escalationChoices, number, color } = this.props;
const { data, escalationChoices, number, backgroundColor, isDisabled } = this.props;
const { id, step, is_final } = data;
const escalationOption = escalationChoices.find(
@ -62,14 +64,20 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
);
return (
<Timeline.Item key={id} contentClassName={cx('root')} number={number} color={color}>
<Timeline.Item
key={id}
contentClassName={cx('root')}
number={number}
textColor={isDisabled ? getVar('--tag-text-success') : undefined}
backgroundColor={backgroundColor}
>
<WithPermissionControlTooltip disableByPaywall userAction={UserActions.EscalationChainsWrite}>
<DragHandle />
</WithPermissionControlTooltip>
{escalationOption &&
reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)}
{this._renderNote()}
{is_final ? null : (
{is_final || isDisabled ? null : (
<WithPermissionControlTooltip className={cx('delete')} userAction={UserActions.EscalationChainsWrite}>
<IconButton
name="trash-alt"
@ -142,7 +150,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderNotifyToUsersQueue() {
const { data } = this.props;
const { data, isDisabled } = this.props;
const { notify_to_users_queue } = data;
return (
@ -155,6 +163,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
isMulti
showSearch
allowClear
disabled={isDisabled}
modelName="userStore"
displayField="username"
valueField="pk"
@ -170,7 +179,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private renderImportance() {
const { data } = this.props;
const { data, isDisabled } = this.props;
const { important } = data;
return (
@ -178,6 +187,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
<Select
menuShouldPortal
className={cx('select', 'control')}
disabled={isDisabled}
value={Number(important)}
// @ts-ignore
onChange={this._getOnSelectChangeHandler('important')}
@ -214,13 +224,14 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private renderTimeRange() {
const { data } = this.props;
const { data, isDisabled } = this.props;
return (
<WithPermissionControlTooltip key="time-range" disableByPaywall userAction={UserActions.EscalationChainsWrite}>
<TimeRange
from={data.from_time}
to={data.to_time}
disabled={isDisabled}
onChange={this._getOnTimeRangeChangeHandler()}
className={cx('select', 'control')}
/>
@ -229,13 +240,14 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderWaitDelays() {
const { data, waitDelays = [] } = this.props;
const { data, isDisabled, waitDelays = [] } = this.props;
const { wait_delay } = data;
return (
<WithPermissionControlTooltip key="wait-delay" disableByPaywall userAction={UserActions.EscalationChainsWrite}>
<Select
menuShouldPortal
disabled={isDisabled}
placeholder="Select Wait Delay"
className={cx('select', 'control')}
// @ts-ignore
@ -252,7 +264,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private renderNumAlertsInWindow() {
const { data } = this.props;
const { data, isDisabled } = this.props;
const { num_alerts_in_window } = data;
return (
@ -263,6 +275,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
>
<Input
placeholder="Count"
disabled={isDisabled}
className={cx('control')}
value={num_alerts_in_window}
onChange={this._getOnInputChangeHandler('num_alerts_in_window')}
@ -278,7 +291,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private renderNumMinutesInWindowOptions() {
const { data, numMinutesInWindowOptions = [] } = this.props;
const { data, isDisabled, numMinutesInWindowOptions = [] } = this.props;
const { num_minutes_in_window } = data;
return (
@ -289,6 +302,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
>
<Select
menuShouldPortal
disabled={isDisabled}
placeholder="Period"
className={cx('select', 'control')}
// @ts-ignore
@ -304,7 +318,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderNotifySchedule() {
const { data, teamStore, scheduleStore } = this.props;
const { data, isDisabled, teamStore, scheduleStore } = this.props;
const { notify_schedule } = data;
return (
@ -316,6 +330,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
<GSelect
showSearch
allowClear
disabled={isDisabled}
modelName="scheduleStore"
displayField="name"
valueField="id"
@ -338,7 +353,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderNotifyUserGroup() {
const { data } = this.props;
const { data, isDisabled } = this.props;
const { notify_to_group } = data;
return (
@ -348,6 +363,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
userAction={UserActions.EscalationChainsWrite}
>
<GSelect
disabled={isDisabled}
modelName="userGroupStore"
displayField="name"
valueField="id"
@ -362,13 +378,14 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderTriggerCustomAction() {
const { data, teamStore, outgoingWebhookStore } = this.props;
const { data, isDisabled, teamStore, outgoingWebhookStore } = this.props;
const { custom_button_trigger } = data;
return (
<WithPermissionControlTooltip key="custom-button" disableByPaywall userAction={UserActions.EscalationChainsWrite}>
<GSelect
showSearch
disabled={isDisabled}
modelName="outgoingWebhookStore"
displayField="name"
valueField="id"
@ -392,7 +409,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
}
private _renderTriggerCustomWebhook() {
const { data, teamStore, outgoingWebhook2Store } = this.props;
const { data, isDisabled, teamStore, outgoingWebhook2Store } = this.props;
const { custom_webhook } = data;
return (
@ -403,6 +420,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
>
<GSelect
showSearch
disabled={isDisabled}
modelName="outgoingWebhook2Store"
displayField="name"
valueField="id"
@ -489,4 +507,4 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
};
}
export default SortableElement(EscalationPolicy);
export default SortableElement(EscalationPolicy) as React.ComponentClass<EscalationPolicyProps>;

View file

@ -52,7 +52,7 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
const { id, step } = data;
return (
<Timeline.Item className={cx('root')} number={number} color={color}>
<Timeline.Item className={cx('root')} number={number} backgroundColor={color}>
<div className={cx('step')}>
<WithPermissionControlTooltip disableByPaywall userAction={userAction}>
<DragHandle />

View file

@ -3,9 +3,9 @@ import React, { FC, useEffect, useState } from 'react';
import { Tooltip, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import CounterBadge from 'components/CounterBadge/CounterBadge';
import PluginLink from 'components/PluginLink/PluginLink';
import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails';
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { Schedule, ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
@ -40,8 +40,8 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
<>
<div className={cx('root')}>
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
<StatusCounterBadgeWithTooltip
type="link"
<CounterBadge
borderType="link"
addPadding
count={schedule.number_of_escalation_chains}
tooltipTitle="Used in escalations"
@ -60,8 +60,8 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
)}
{schedule.warnings?.length > 0 && (
<StatusCounterBadgeWithTooltip
type="warning"
<CounterBadge
borderType="warning"
addPadding
count={schedule.warnings.length}
tooltipTitle="Warnings"

View file

@ -37,13 +37,6 @@ $width: 340px;
margin-bottom: 4px;
}
.line-break {
width: 100%;
border-top: 1px solid var(--always-gray);
margin-top: 8px;
opacity: 15%;
}
.metholodogy {
padding: 4px 0px;
}

View file

@ -96,7 +96,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
)}
</div>
<div className={cx('line-break')} />
<div className="thin-line-break" />
<div className={cx('container', 'container--withTopPadding', 'container--withLateralPadding')}>
<HorizontalGroup justify="space-between">

View file

@ -1,449 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SourceCode It renders at 0% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--danger"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--danger"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--danger"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--danger"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--danger"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 25% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 25%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 30% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 50%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 50% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 50%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--warning"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 65% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 25%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 70% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 50%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 0%;"
/>
</div>
</div>
</div>
`;
exports[`SourceCode It renders at 100% 1`] = `
<div>
<div
class="c-progressBar__wrapper"
>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
<div
class="c-progressBar__row c-progressBar__row--progress"
data-testid="progressBar__row"
style="width: 20%;"
>
<div
class="c-progressBar__bar c-progressBar__bar--primary"
data-testid="progressBar__bar"
style="width: 100%;"
/>
</div>
</div>
</div>
`;

View file

@ -15,10 +15,11 @@ interface SourceCodeProps {
showClipboardIconOnly?: boolean;
showCopyToClipboard?: boolean;
children?: any;
className?: string;
}
const SourceCode: FC<SourceCodeProps> = (props) => {
const { children, noMaxHeight = false, showClipboardIconOnly = false, showCopyToClipboard = true } = props;
const { children, noMaxHeight = false, showClipboardIconOnly = false, showCopyToClipboard = true, className } = props;
const showClipboardCopy = showClipboardIconOnly || showCopyToClipboard;
return (
@ -48,9 +49,13 @@ const SourceCode: FC<SourceCodeProps> = (props) => {
</CopyToClipboard>
)}
<pre
className={cx('scroller', {
'scroller--maxHeight': !noMaxHeight,
})}
className={cx(
'scroller',
{
'scroller--maxHeight': !noMaxHeight,
},
className
)}
>
<code>{children}</code>
</pre>

View file

@ -1,35 +0,0 @@
.element {
font-size: 12px;
line-height: 16px;
padding: 3px 4px;
&--link {
background: rgba(27, 133, 94, 0.15);
border: 1px solid var(--tag-border-success);
border-radius: 2px;
}
&--warning {
background: rgba(245, 183, 61, 0.18);
border: 1px solid var(--tag-border-warning);
border-radius: 2px;
}
&--padding {
padding: 3px 10px;
}
}
.element__text--link,
.element__icon--link {
color: var(--tag-text-success);
}
.element__text--warning,
.element__icon--warning {
color: var(--tag-text-warning);
}
.tooltip {
width: auto;
}

View file

@ -1,58 +0,0 @@
import React, { FC } from 'react';
import { HorizontalGroup, VerticalGroup, Icon, Tooltip, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
import Text, { TextType } from 'components/Text/Text';
import styles from './StatusCounterBadgeWithTooltip.module.scss';
interface StatusCounterBadgeWithTooltipProps {
type: Partial<TextType>;
count: number;
tooltipTitle: string;
tooltipContent: React.ReactNode;
addPadding?: boolean;
onHover?: () => void;
}
const typeToIcon = {
link: 'link',
warning: 'exclamation-triangle',
};
const cx = cn.bind(styles);
const StatusCounterBadgeWithTooltip: FC<StatusCounterBadgeWithTooltipProps> = (props) => {
const { type, count, tooltipTitle, tooltipContent, onHover, addPadding } = props;
return (
<Tooltip
placement="bottom-start"
interactive
content={
<div className={cx('tooltip')}>
<VerticalGroup>
<Text type="secondary">{tooltipTitle}</Text>
<Text type="secondary">{tooltipContent}</Text>
</VerticalGroup>
</div>
}
>
<div
className={cx('root', 'element', { [`element--${type}`]: true }, { 'element--padding': addPadding })}
onMouseEnter={onHover}
>
<HorizontalGroup spacing="xs">
<Icon
className={cx('element__icon', { [`element__icon--${type}`]: true })}
name={typeToIcon[type] as IconName}
/>
<Text className={cx('element__text', { [`element__text--${type}`]: true })}>{count}</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
export default StatusCounterBadgeWithTooltip;

View file

@ -1,5 +1,6 @@
.root {
border-radius: 2px;
padding: 1px 7px 4px 7px;
line-height: 100%;
padding: 4px 7px;
color: white;
}

View file

@ -9,7 +9,9 @@ const cx = cn.bind(styles);
export interface TimelineItemProps {
className?: string;
contentClassName?: string;
color?: string;
isDisabled?: boolean;
backgroundColor?: string;
textColor?: string;
number?: number;
badge?: number;
children?: any;
@ -19,15 +21,21 @@ const TimelineItem: React.FC<TimelineItemProps> = ({
className,
contentClassName,
children,
color = '#3274D9',
isDisabled,
backgroundColor = '#3274D9',
textColor = '#ffffff',
number,
}) => (
<li className={cx('item', className)}>
<div className={cx('dot')} style={{ backgroundColor: color }}>
{number}
</div>
<div className={cx('content', contentClassName)}>{children}</div>
</li>
);
}) => {
return (
<li className={cx('item', className)}>
{!isDisabled && (
<div className={cx('dot')} style={{ backgroundColor, color: textColor }}>
{number}
</div>
)}
<div className={cx('content', contentClassName)}>{children}</div>
</li>
);
};
export default TimelineItem;

View file

@ -1,241 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Unauthorized renders properly - access control enabled: false 1`] = `
<div
className="not-found"
>
<div
className="css-9ztktj-vertical-group"
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h1
className="title error-code"
>
<span
className="root text text--undefined text--medium"
>
403
</span>
</h1>
</div>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h4
className="title"
>
<span
className="root text text--undefined text--medium"
>
You do not have access to view this page.
You must be at least an Admin.
<br />
<br />
Please contact your organization administrator to request access.
</span>
</h4>
</div>
</div>
</div>
`;
exports[`Unauthorized renders properly - access control enabled: true 1`] = `
<div
className="not-found"
>
<div
className="css-9ztktj-vertical-group"
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h1
className="title error-code"
>
<span
className="root text text--undefined text--medium"
>
403
</span>
</h1>
</div>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h4
className="title"
>
<span
className="root text text--undefined text--medium"
>
You do not have access to view this page.
You are missing the grafana-oncall-app.testing:hi permission.
<br />
<br />
Please contact your organization administrator to request access.
</span>
</h4>
</div>
</div>
</div>
`;
exports[`Unauthorized renders properly the grammar for different roles - Admin 1`] = `
<div
className="not-found"
>
<div
className="css-9ztktj-vertical-group"
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h1
className="title error-code"
>
<span
className="root text text--undefined text--medium"
>
403
</span>
</h1>
</div>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h4
className="title"
>
<span
className="root text text--undefined text--medium"
>
You do not have access to view this page.
You must be at least an Admin.
<br />
<br />
Please contact your organization administrator to request access.
</span>
</h4>
</div>
</div>
</div>
`;
exports[`Unauthorized renders properly the grammar for different roles - Editor 1`] = `
<div
className="not-found"
>
<div
className="css-9ztktj-vertical-group"
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h1
className="title error-code"
>
<span
className="root text text--undefined text--medium"
>
403
</span>
</h1>
</div>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h4
className="title"
>
<span
className="root text text--undefined text--medium"
>
You do not have access to view this page.
You must be at least an Editor.
<br />
<br />
Please contact your organization administrator to request access.
</span>
</h4>
</div>
</div>
</div>
`;
exports[`Unauthorized renders properly the grammar for different roles - Viewer 1`] = `
<div
className="not-found"
>
<div
className="css-9ztktj-vertical-group"
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h1
className="title error-code"
>
<span
className="root text text--undefined text--medium"
>
403
</span>
</h1>
</div>
<div
className="css-1ec9088-layoutChildrenWrapper"
>
<h4
className="title"
>
<span
className="root text text--undefined text--medium"
>
You do not have access to view this page.
You must be at least a Viewer.
<br />
<br />
Please contact your organization administrator to request access.
</span>
</h4>
</div>
</div>
</div>
`;

View file

@ -4,7 +4,7 @@ import { ContextMenu } from '@grafana/ui';
export interface WithContextMenuProps {
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
renderMenuItems: () => React.ReactNode;
renderMenuItems: ({ closeMenu }: { closeMenu?: () => void }) => React.ReactNode;
forceIsOpen?: boolean;
focusOnOpen?: boolean;
}
@ -52,7 +52,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
onClose={() => setIsMenuOpen(false)}
x={menuPosition.x}
y={menuPosition.y}
renderMenuItems={renderMenuItems}
renderMenuItems={() => renderMenuItems({ closeMenu: () => setIsMenuOpen(false) })}
focusOnOpen={focusOnOpen}
/>
)}

View file

@ -29,7 +29,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
}
return (
<Timeline.Item number={0} color={getVar('--tag-secondary')}>
<Timeline.Item number={0} backgroundColor={getVar('--tag-secondary')}>
<VerticalGroup>
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}

View file

@ -21,12 +21,13 @@ const cx = cn.bind(styles);
interface EscalationChainStepsProps {
id: EscalationChain['id'];
isDisabled?: boolean;
addonBefore?: ReactElement;
offset?: number;
}
const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
const { id, offset = 0, addonBefore } = props;
const { id, offset = 0, isDisabled = false, addonBefore } = props;
const store = useStore();
@ -76,11 +77,9 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
return (
<EscalationPolicy
key={`item-${escalationPolicy.id}`}
index={index}
// @ts-ignore
data={escalationPolicy}
number={index + offset + 1}
color={STEP_COLORS[index] || COLOR_RED}
backgroundColor={isDisabled ? getVar('--tag-background-success') : STEP_COLORS[index] || COLOR_RED}
escalationChoices={escalationPolicyStore.webEscalationChoices}
waitDelays={get(escalationPolicyStore.escalationChoices, 'wait_delay.choices', [])}
numMinutesInWindowOptions={escalationPolicyStore.numMinutesInWindowOptions}
@ -91,27 +90,34 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
scheduleStore={store.scheduleStore}
outgoingWebhookStore={store.outgoingWebhookStore}
outgoingWebhook2Store={store.outgoingWebhook2Store}
isDisabled={isDisabled}
/>
);
})
) : (
<LoadingPlaceholder text="Loading..." />
)}
<Timeline.Item number={(escalationPolicyIds?.length || 0) + offset + 1} color={getVar('--tag-secondary')}>
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
<Select
isSearchable
menuShouldPortal
placeholder="Add escalation step..."
onChange={handleCreateEscalationStep}
options={escalationPolicyStore.webEscalationChoices.map((choice: EscalationPolicyOption) => ({
value: choice.value,
label: choice.create_display_name,
}))}
value={null}
/>
</WithPermissionControlTooltip>
</Timeline.Item>
{!isDisabled && (
<Timeline.Item
number={(escalationPolicyIds?.length || 0) + offset + 1}
backgroundColor={isDisabled ? getVar('--tag-background-success') : getVar('--tag-secondary')}
textColor={isDisabled ? getVar('--tag-text-success') : undefined}
>
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
<Select
isSearchable
menuShouldPortal
placeholder="Add escalation step..."
onChange={handleCreateEscalationStep}
options={escalationPolicyStore.webEscalationChoices.map((choice: EscalationPolicyOption) => ({
value: choice.value,
label: choice.create_display_name,
}))}
value={null}
/>
</WithPermissionControlTooltip>
</Timeline.Item>
)}
</SortableList>
);
});

View file

@ -1,3 +0,0 @@
.root {
min-width: 200px;
}

View file

@ -0,0 +1,8 @@
.root {
min-width: 200px;
& > div {
// If not set then inner div will not benefit of min-width
min-width: 200px;
}
}

View file

@ -8,13 +8,13 @@ import { observer } from 'mobx-react';
import { useStore } from 'state/useStore';
import styles from './GSelect.module.css';
// import { debounce } from 'lodash';
import styles from './GSelect.module.scss';
const cx = cn.bind(styles);
interface GSelectProps {
placeholder: string;
isLoading?: boolean;
value?: string | string[] | null;
defaultValue?: string | string[] | null;
onChange: (value: string, item: any) => void;
@ -45,6 +45,7 @@ const GSelect = observer((props: GSelectProps) => {
autoFocus,
showSearch = false,
allowClear = false,
isLoading,
defaultOpen,
placeholder,
className,
@ -157,6 +158,7 @@ const GSelect = observer((props: GSelectProps) => {
onChange={onChangeCallback}
defaultOptions={!disabled}
loadOptions={loadOptions}
isLoading={isLoading}
// @ts-ignore
value={values}
defaultValue={defaultValue}

View file

@ -0,0 +1,30 @@
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
export const form: { name: string; fields: FormItem[] } = {
name: 'Integration',
fields: [
{
name: 'verbal_name',
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'description',
type: FormItemType.TextArea,
},
{
name: 'team',
label: 'Assign to team',
description:
'Assigning to the teams allows you to filter Integrations and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
},
},
],
};

View file

@ -0,0 +1,9 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel';
export function prepareForEdit(item: AlertReceiveChannel) {
return {
verbal_name: item.verbal_name,
// description: item.description,
team: item.team,
};
}

View file

@ -0,0 +1,77 @@
.content {
margin: 4px 4px 50px 4px;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 24px;
overflow: auto;
scroll-snap-type: y mandatory;
padding: 0 10px 10px 0;
width: 100%;
}
.cards_centered {
justify-content: center;
align-items: center;
}
.card {
width: 48%;
height: 88px;
scroll-snap-align: start;
scroll-snap-stop: normal;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
position: relative;
gap: 20px;
}
.card_featured {
width: 100%;
}
.tag {
top: 28px;
right: 28px;
position: absolute;
}
.title {
margin: 10px 0 10px 0;
max-width: 500px;
}
.footer {
display: block;
margin-top: 10px;
}
.search-integration {
width: 400px;
margin-bottom: 24px;
}
.collapse {
width: 100%;
margin-bottom: 24px;
}
.collapsable-content {
width: 100%;
background-color: var(--background-secondary);
font-size: small;
}
.integration-info-list {
list-style-position: inside;
margin: 16px 0;
}
.integration-info-item {
margin-left: 16px;
}

View file

@ -0,0 +1,204 @@
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 Collapse from 'components/Collapse/Collapse';
import Block from 'components/GBlock/Block';
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 { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
import { form } from './IntegrationForm.config';
import { prepareForEdit } from './IntegrationForm.helpers';
import styles from './IntegrationForm.module.css';
const cx = cn.bind(styles);
interface IntegrationFormProps {
id: AlertReceiveChannel['id'] | 'new';
onHide: () => void;
onUpdate: () => void;
}
const IntegrationForm = observer((props: IntegrationFormProps) => {
const { id, onHide, onUpdate } = props;
const store = useStore();
const { alertReceiveChannelStore, userStore } = store;
const user = userStore.currentUser;
const [filterValue, setFilterValue] = useState('');
const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false);
// const [showIntegrationListForm, setShowIntegrationListForm] = useState(false);
const [selectedOption, setSelectedOption] = useState<AlertReceiveChannelOption>(undefined);
const data =
id === 'new'
? { integration: selectedOption?.value, team: user.current_team }
: prepareForEdit(alertReceiveChannelStore.items[id]);
const integration = alertReceiveChannelStore.getIntegration(data);
const handleSubmit = useCallback(
(data: Partial<AlertReceiveChannel>) => {
(id === 'new' ? alertReceiveChannelStore.create(data) : alertReceiveChannelStore.update(id, data)).then(() => {
onHide();
onUpdate();
});
},
[id]
);
const handleNewIntegrationOptionSelectCallback = useCallback((option: AlertReceiveChannelOption) => {
return () => {
setSelectedOption(option);
setShowNewIntegrationForm(true);
};
}, []);
const handleChangeFilter = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setFilterValue(e.currentTarget.value);
}, []);
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
const options = alertReceiveChannelOptions
? alertReceiveChannelOptions.filter((option: AlertReceiveChannelOption) =>
option.display_name.toLowerCase().includes(filterValue.toLowerCase())
)
: [];
return (
<>
{id === 'new' && (
<Drawer scrollableContent title="New Integration" onClose={onHide} closeOnMaskClick={false}>
<div className={cx('content')}>
<VerticalGroup>
<div className={cx('search-integration')}>
<Input
autoFocus
value={filterValue}
placeholder="Search integrations ..."
onChange={handleChangeFilter}
/>
</div>
<div className={cx('cards')} data-testid="create-integration-modal">
{options.length ? (
options.map((alertReceiveChannelChoice) => {
return (
<Block
bordered
shadowed
onClick={handleNewIntegrationOptionSelectCallback(alertReceiveChannelChoice)}
key={alertReceiveChannelChoice.value}
className={cx('card', { card_featured: alertReceiveChannelChoice.featured })}
>
<div className={cx('card-bg')}>
<IntegrationLogo integration={alertReceiveChannelChoice} scale={0.2} />
</div>
<div className={cx('title')}>
<VerticalGroup spacing="none">
<Text strong data-testid="integration-display-name">
{alertReceiveChannelChoice.display_name}
</Text>
<Text type="secondary" size="small">
{alertReceiveChannelChoice.short_description}
</Text>
</VerticalGroup>
</div>
{alertReceiveChannelChoice.featured && (
<Tag name="Quick connect" className={cx('tag')} colorIndex={7} />
)}
</Block>
);
})
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
</div>
</VerticalGroup>
</div>
</Drawer>
)}
{(showNewIntegrationForm || id !== 'new') && (
<Drawer
scrollableContent
title={
id === 'new'
? `New ${selectedOption?.display_name} integration`
: `Edit ${integration?.display_name} integration`
}
onClose={onHide}
closeOnMaskClick={false}
>
<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>
<HorizontalGroup justify="flex-end">
{id === 'new' ? (
<Button variant="secondary" onClick={() => setShowNewIntegrationForm(false)}>
Back
</Button>
) : (
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
)}
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
<Button form={form.name} type="submit">
{id === 'new' ? 'Create' : 'Update'} Integration
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
</div>
</Drawer>
)}
</>
);
});
export default IntegrationForm;

View file

@ -0,0 +1,46 @@
.title-container {
padding: 24px;
margin-bottom: 24px;
}
.container {
display: flex;
width: 100%;
border: var(--border-weak);
}
.template-block-title {
padding: 16px;
align-items: baseline;
}
.template-editor-block-title {
padding: 16px;
align-items: baseline;
border: var(--border-weak);
background-color: var(--background-secondary);
}
.template-block-list {
width: 30%;
height: 100%;
}
.template-block-codeeditor {
width: 40%;
height: 100%;
}
.template-block-result {
width: 30%;
height: 100%;
}
.result {
padding: 16px;
}
.block-style {
border: var(--border-weak);
background-color: var(--background-secondary);
}

View file

@ -0,0 +1,277 @@
import React, { useCallback, useState } from 'react';
import { Button, HorizontalGroup, Drawer, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import { TemplateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import CheatSheet from 'components/CheatSheet/CheatSheet';
import {
groupingTemplateCheatSheet,
slackMessageTemplateCheatSheet,
webTitleTemplateCheatSheet,
} from 'components/CheatSheet/CheatSheet.config';
import Block from 'components/GBlock/Block';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
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 { Alert } from 'models/alertgroup/alertgroup.types';
import styles from './IntegrationTemplate.module.css';
const cx = cn.bind(styles);
interface IntegrationTemplateProps {
id: AlertReceiveChannel['id'];
template: TemplateForEdit;
templateBody: string;
onHide: () => void;
onUpdateTemplates: (values: any) => void;
onUpdateRoute: (values: any) => void;
}
const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const { id, onHide, template, onUpdateTemplates, onUpdateRoute, templateBody } = props;
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState<boolean>(false);
const [chatOps, setChatOps] = useState(undefined);
const [alertGroupPayload, setAlertGroupPayload] = useState<JSON>(undefined);
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(templateBody);
const [resultError, setResultError] = useState<string>(undefined);
const onShowCheatSheet = useCallback(() => {
setIsCheatSheetVisible(true);
}, []);
const onCloseCheatSheet = useCallback(() => {
setIsCheatSheetVisible(false);
}, []);
const getChangeHandler = () => {
return debounce((value: string) => {
setChangedTemplateBody(value);
}, 1000);
};
const onEditPayload = (alertPayload: string) => {
if (alertPayload !== null) {
try {
const jsonPayload = JSON.parse(alertPayload);
if (typeof jsonPayload === 'object') {
setResultError(undefined);
setAlertGroupPayload(JSON.parse(alertPayload));
} else {
setResultError('Please check your JSON format');
}
} catch (e) {
setResultError(e.message);
}
} else {
setResultError(undefined);
setAlertGroupPayload(undefined);
}
};
const onSelectAlertGroup = useCallback((alertGroup: Alert) => {
if (template.additionalData?.chatOpsName) {
setChatOps({
permalink: alertGroup?.permalinks[template.additionalData?.chatOpsName],
name: template.additionalData?.chatOpsName,
comment: template.additionalData?.data,
});
}
}, []);
const onSaveAndFollowLink = useCallback(
(link: string) => {
onHide();
window.open(link, '_blank');
},
[onUpdateTemplates, onUpdateRoute, changedTemplateBody]
);
const handleSubmit = useCallback(() => {
template.isRoute
? onUpdateRoute({ [template.name]: changedTemplateBody })
: onUpdateTemplates({ [template.name]: changedTemplateBody });
onHide();
}, [onUpdateTemplates, changedTemplateBody]);
const getCheatSheet = (templateName) => {
switch (templateName) {
case 'Grouping':
case 'Autoresolve':
return groupingTemplateCheatSheet;
case 'Web titile':
case 'Web message':
case 'Web image':
return webTitleTemplateCheatSheet;
case 'Auto acknowledge':
case 'Source link':
case 'Phone call':
case 'SMS':
case 'Slack title':
case 'Slack message':
case 'Slack image':
case 'Telegram title':
case 'Telegram message':
case 'Telegram image':
case 'Email title':
case 'Email message':
return slackMessageTemplateCheatSheet;
default:
return webTitleTemplateCheatSheet;
}
};
return (
<>
<Drawer
title={
<div className={cx('title-container')}>
<HorizontalGroup justify="space-between" align="flex-start">
<VerticalGroup>
<Text.Title level={3}>Edit {template.displayName} template</Text.Title>
{template.description && <Text type="secondary">{template.description}</Text>}
</VerticalGroup>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit}>
Save
</Button>
</HorizontalGroup>
</HorizontalGroup>
</div>
}
onClose={onHide}
closeOnMaskClick={false}
width={'95%'}
>
<div className={cx('container')}>
<TemplatesAlertGroupsList
alertReceiveChannelId={id}
onEditPayload={onEditPayload}
onSelectAlertGroup={onSelectAlertGroup}
/>
{isCheatSheetVisible ? (
<CheatSheet cheatSheetData={getCheatSheet(template.displayName)} onClose={onCloseCheatSheet} />
) : (
<>
<div className={cx('template-block-codeeditor')}>
<div className={cx('template-editor-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Template editor</Text>
<Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
Cheatsheat
</Button>
</HorizontalGroup>
</div>
<MonacoJinja2Editor
value={templateBody}
data={undefined}
showLineNumbers={true}
height={'100vh'}
onChange={getChangeHandler()}
/>
</div>
</>
)}
{/* {alertGroupPayload || resultError ? ( */}
<Result
alertReceiveChannelId={id}
templateName={template.name}
templateBody={changedTemplateBody}
alertGroup={undefined}
chatOps={chatOps}
payload={alertGroupPayload}
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>
</Drawer>
</>
);
});
interface ResultProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
templateName: string;
templateBody: string;
alertGroup?: Alert;
chatOps?: { permalink: string; name: string; comment?: string };
payload?: JSON;
error?: string;
onSaveAndFollowLink?: (link: string) => void;
}
const Result = (props: ResultProps) => {
const { alertReceiveChannelId, templateName, chatOps, payload, templateBody, error, onSaveAndFollowLink } = props;
return (
<div className={cx('template-block-result')}>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Result</Text>
</HorizontalGroup>
</div>
<div className={cx('result')}>
{payload || error ? (
<VerticalGroup spacing="lg">
{error ? (
<Block bordered fullWidth withBackground>
<Text>{error}</Text>
</Block>
) : (
<Block bordered fullWidth className={cx('block-style')}>
<TemplatePreview
key={templateName}
templateName={templateName}
templateBody={templateBody}
alertReceiveChannelId={alertReceiveChannelId}
payload={payload}
/>
</Block>
)}
{chatOps && (
<VerticalGroup>
<Button onClick={() => onSaveAndFollowLink(chatOps.permalink)}>
Save and open Alert Group in {chatOps.name}
</Button>
{chatOps.comment && (
<Text type="secondary">
Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.
</Text>
)}
</VerticalGroup>
)}
</VerticalGroup>
) : (
<div>
<Block bordered fullWidth className={cx('block-style')}>
<Text>You do not have any input data to render result. Please select Alert group to see end result</Text>
</Block>
</div>
)}
</div>
</div>
);
};
export default IntegrationTemplate;

View file

@ -282,7 +282,7 @@ function QRLoading() {
<Text type="primary" className={cx('qr-loader__text')}>
Regenerating QR code...
</Text>
<LoadingPlaceholder />
<LoadingPlaceholder text="Loading..." />
</div>
);
}

View file

@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DisconnectButton it renders properly 1`] = `
<div>
<button
class="css-mk7eo3-button disconnect-button"
data-testid="test__disconnect"
type="button"
>
<span
class="css-1mhnkuh"
>
Disconnect
</span>
</button>
</div>
`;

View file

@ -1,88 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DownloadIcons it renders properly 1`] = `
<div>
<div
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-ztyofd-layoutChildrenWrapper"
>
<span
class="root text text--primary text--medium text--strong"
>
Download
</span>
</div>
<div
class="css-ztyofd-layoutChildrenWrapper"
>
<span
class="root text text--primary text--medium"
>
The Grafana IRM app is available on both the App Store and Google Play Store.
</span>
</div>
<div
class="css-ztyofd-layoutChildrenWrapper"
>
<div
class="css-1j7sh2x-vertical-group"
style="width: 100%; height: 100%;"
>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://apps.apple.com/us/app/grafana-oncall-preview/id1669759048"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--withBackground root--hover"
>
<img
alt="Apple"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
iOS
</span>
</div>
</a>
</div>
<div
class="css-bxa289-layoutChildrenWrapper"
>
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -153,7 +153,7 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
store={store}
/>
))}
<Timeline.Item number={notificationPolicies.length + 1} color={getColor(notificationPolicies.length)}>
<Timeline.Item number={notificationPolicies.length + 1} backgroundColor={getColor(notificationPolicies.length)}>
<div className={cx('step')}>
<WithPermissionControlTooltip userAction={userAction}>
<Button icon="plus" variant="secondary" fill="text" onClick={getAddNotificationPolicyHandler()}>

View file

@ -1,449 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonData, and ONCALL_API_URL is passed in process.env, and there is an error calling selfHostedInstallPlugin, it sets an error message 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
ohhh nooo an error msg from self hosted install plugin
</span>
</pre>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
`;
exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, checkIfPluginIsConnected is not called, and the configuration form is shown 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<form
class="css-xs0vux"
data-testid="plugin-configuration-form"
>
<div
class="info-block"
>
<p>
1. Launch the OnCall backend
</p>
<span
class="root text text--secondary text--medium"
>
Run hobby, dev or production backend. See
<a
href="https://github.com/grafana/oncall#getting-started"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
on how to get started.
</span>
</div>
<div
class="info-block"
>
<p>
2. Let us know the base URL of your OnCall API
</p>
<span
class="root text text--secondary text--medium"
>
The OnCall backend must be reachable from your Grafana installation. Some examples are:
<br />
- http://host.docker.internal:8080
<br />
- http://localhost:8080
</span>
</div>
<div
class="css-8e5b3"
>
<div
class="css-jt4xma-Label"
>
<label>
<div
class="css-xhqy0o"
>
OnCall backend URL
</div>
</label>
</div>
<div>
<div
class="css-xcstkt-input-wrapper"
data-testid="input-wrapper"
>
<div
class="css-1w5c5dq-input-inputWrapper"
>
<input
class="css-1wdli31-input-input"
data-testid="onCallApiUrl"
name="onCallApiUrl"
/>
</div>
</div>
</div>
</div>
<button
class="css-1sara2j-button"
type="submit"
>
<span
class="css-1mhnkuh"
>
Connect
</span>
</button>
</form>
</div>
`;
exports[`PluginConfigPage If onCallApiUrl is set, and checkIfPluginIsConnected returns an error, it sets an error message 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
ohhh nooo a plugin connection error
</span>
</pre>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
`;
exports[`PluginConfigPage It doesn't make any network calls if the plugin configured query params are provided 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
Connected to OnCall (v1.2.3, OpenSource)
</span>
</pre>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
`;
exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not return an error. It displays properly the plugin connected items based on the license - License: OpenSource 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
Connected to OnCall (v1.2.3, OpenSource)
</span>
</pre>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
`;
exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not return an error. It displays properly the plugin connected items based on the license - License: some-other-license 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
Connected to OnCall (v1.2.3, some-other-license)
</span>
</pre>
<div
class="css-jt4xma-Label"
>
<label>
<div
class="css-xhqy0o"
>
This is a cloud managed configuration.
</div>
</label>
</div>
</div>
`;
exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall returns an error 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
ohhh noooo a sync issue
</span>
</pre>
<button
class="css-1sara2j-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry Sync
</span>
</button>
</div>
`;
exports[`PluginConfigPage Plugin reset: successful - false 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
There was an error resetting your plugin, try again.
</span>
</pre>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
`;
exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
<div>
<legend
class="css-xim8hk"
>
Configure Grafana OnCall
</legend>
<p>
This page will help you configure the OnCall plugin 👋
</p>
<form
class="css-xs0vux"
data-testid="plugin-configuration-form"
>
<div
class="info-block"
>
<p>
1. Launch the OnCall backend
</p>
<span
class="root text text--secondary text--medium"
>
Run hobby, dev or production backend. See
<a
href="https://github.com/grafana/oncall#getting-started"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
on how to get started.
</span>
</div>
<div
class="info-block"
>
<p>
2. Let us know the base URL of your OnCall API
</p>
<span
class="root text text--secondary text--medium"
>
The OnCall backend must be reachable from your Grafana installation. Some examples are:
<br />
- http://host.docker.internal:8080
<br />
- http://localhost:8080
</span>
</div>
<div
class="css-8e5b3"
>
<div
class="css-jt4xma-Label"
>
<label>
<div
class="css-xhqy0o"
>
OnCall backend URL
</div>
</label>
</div>
<div>
<div
class="css-xcstkt-input-wrapper"
data-testid="input-wrapper"
>
<div
class="css-1w5c5dq-input-inputWrapper"
>
<input
class="css-1wdli31-input-input"
data-testid="onCallApiUrl"
name="onCallApiUrl"
/>
</div>
</div>
</div>
</div>
<button
class="css-1sara2j-button"
type="submit"
>
<span
class="css-1mhnkuh"
>
Connect
</span>
</button>
</form>
</div>
`;

View file

@ -1,278 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfigurationForm It doesn't allow the user to submit if the URL is invalid 1`] = `
<body>
<div>
<form
class="css-xs0vux"
data-testid="plugin-configuration-form"
>
<div
class="info-block"
>
<p>
1. Launch the OnCall backend
</p>
<span
class="root text text--secondary text--medium"
>
Run hobby, dev or production backend. See
<a
href="https://github.com/grafana/oncall#getting-started"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
on how to get started.
</span>
</div>
<div
class="info-block"
>
<p>
2. Let us know the base URL of your OnCall API
</p>
<span
class="root text text--secondary text--medium"
>
The OnCall backend must be reachable from your Grafana installation. Some examples are:
<br />
- http://host.docker.internal:8080
<br />
- http://localhost:8080
</span>
</div>
<div
class="css-8e5b3"
>
<div
class="css-jt4xma-Label"
>
<label>
<div
class="css-xhqy0o"
>
OnCall backend URL
</div>
</label>
</div>
<div>
<div
class="css-1wz1ggz-input-wrapper"
data-testid="input-wrapper"
>
<div
class="css-1w5c5dq-input-inputWrapper"
>
<input
class="css-uzu9xn-input-input"
data-testid="onCallApiUrl"
name="onCallApiUrl"
/>
</div>
</div>
<div
class="css-1gd2lua"
>
<div
class="css-mw0th3"
role="alert"
>
<div
class="css-wf08df-Icon"
>
<svg
class="css-1ah41zt"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12,16a1,1,0,1,0,1,1A1,1,0,0,0,12,16Zm10.67,1.47-8.05-14a3,3,0,0,0-5.24,0l-8,14A3,3,0,0,0,3.94,22H20.06a3,3,0,0,0,2.61-4.53Zm-1.73,2a1,1,0,0,1-.88.51H3.94a1,1,0,0,1-.88-.51,1,1,0,0,1,0-1l8-14a1,1,0,0,1,1.78,0l8.05,14A1,1,0,0,1,20.94,19.49ZM12,8a1,1,0,0,0-1,1v4a1,1,0,0,0,2,0V9A1,1,0,0,0,12,8Z"
/>
</svg>
</div>
Must be a valid URL
</div>
</div>
</div>
</div>
<button
class="css-1sara2j-button"
disabled=""
type="submit"
>
<span
class="css-1mhnkuh"
>
Connect
</span>
</button>
</form>
</div>
</body>
`;
exports[`ConfigurationForm It shows an error message if the self hosted plugin API call fails 1`] = `
<body>
<div>
<form
class="css-xs0vux"
data-testid="plugin-configuration-form"
>
<div
class="info-block"
>
<p>
1. Launch the OnCall backend
</p>
<span
class="root text text--secondary text--medium"
>
Run hobby, dev or production backend. See
<a
href="https://github.com/grafana/oncall#getting-started"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
on how to get started.
</span>
</div>
<div
class="info-block"
>
<p>
2. Let us know the base URL of your OnCall API
</p>
<span
class="root text text--secondary text--medium"
>
The OnCall backend must be reachable from your Grafana installation. Some examples are:
<br />
- http://host.docker.internal:8080
<br />
- http://localhost:8080
</span>
</div>
<div
class="css-8e5b3"
>
<div
class="css-jt4xma-Label"
>
<label>
<div
class="css-xhqy0o"
>
OnCall backend URL
</div>
</label>
</div>
<div>
<div
class="css-xcstkt-input-wrapper"
data-testid="input-wrapper"
>
<div
class="css-1w5c5dq-input-inputWrapper"
>
<input
class="css-1wdli31-input-input"
data-testid="onCallApiUrl"
name="onCallApiUrl"
/>
</div>
</div>
</div>
</div>
<pre>
<span
class="root text text--link text--medium"
>
ohhh nooo there was an error from the OnCall API
</span>
</pre>
<div
class="root info-block root--withBackground"
>
<span
class="root text text--secondary text--medium"
>
Need help?
<br />
- Reach out to the OnCall team in the
<a
href="https://grafana.slack.com/archives/C02LSUUSE2G"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
#grafana-oncall
</span>
</a>
community Slack channel
<br />
- Ask questions on our GitHub Discussions page
<a
href="https://github.com/grafana/oncall/discussions/categories/q-a"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
<br />
- Or file bugs on our GitHub Issues page
<a
href="https://github.com/grafana/oncall/issues"
rel="noreferrer"
target="_blank"
>
<span
class="root text text--link text--medium"
>
here
</span>
</a>
</span>
</div>
<button
class="css-1sara2j-button"
type="submit"
>
<span
class="css-1mhnkuh"
>
Connect
</span>
</button>
</form>
</div>
</body>
`;

View file

@ -1,36 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RemoveCurrentConfigurationButton It renders properly when disabled 1`] = `
<body>
<div>
<button
class="css-mk7eo3-button"
disabled=""
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</body>
`;
exports[`RemoveCurrentConfigurationButton It renders properly when enabled 1`] = `
<body>
<div>
<button
class="css-mk7eo3-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</body>
`;

View file

@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusMessageBlock It renders properly 1`] = `
<body>
<div>
<pre
data-testid="status-message-block"
>
<span
class="root text text--undefined text--medium"
>
helloooo
</span>
</pre>
</div>
</body>
`;

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { LoadingPlaceholder, Alert as AlertComponent } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
@ -18,16 +18,20 @@ const cx = cn.bind(styles);
interface TemplatePreviewProps {
templateName: string;
templateBody: string | null;
payload?: JSON;
alertReceiveChannelId: AlertReceiveChannel['id'];
onEditClick: () => void;
onEditClick?: () => void;
alertGroupId?: Alert['pk'];
active?: boolean;
onResult?: (result) => void;
}
const TemplatePreview = observer((props: TemplatePreviewProps) => {
const { templateName, templateBody, alertReceiveChannelId, alertGroupId } = props;
const { templateName, templateBody, payload, alertReceiveChannelId, alertGroupId } = props;
const [result, setResult] = useState<{ preview: string | null } | undefined>(undefined);
const [isCondition, setIsCondition] = useState(false);
// const [conditionalResult, setConditionalResult] = useState()
const store = useStore();
const { alertReceiveChannelStore, alertGroupStore } = store;
@ -35,9 +39,16 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
const handleTemplateBodyChange = useDebouncedCallback(() => {
(alertGroupId
? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody)
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody)
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)
)
.then(setResult)
.then((data) => {
setResult(data);
if (data?.preview === 'True') {
setIsCondition(true);
} else {
setIsCondition(false);
}
})
.catch((err) => {
if (err.response?.data?.length > 0) {
openErrorNotification(err.response.data);
@ -47,15 +58,33 @@ const TemplatePreview = observer((props: TemplatePreviewProps) => {
});
}, 1000);
useEffect(handleTemplateBodyChange, [templateBody]);
useEffect(handleTemplateBodyChange, [templateBody, payload]);
// onResult(result);
return result ? (
<div
className={cx('message')}
dangerouslySetInnerHTML={{
__html: sanitize(result.preview || ''),
}}
/>
<>
{templateName.includes('condition_template') ? (
<AlertComponent severity={isCondition ? 'success' : 'error'} title="">
{isCondition ? (
'True'
) : (
<div
className={cx('message')}
dangerouslySetInnerHTML={{
__html: sanitize(result.preview || ''),
}}
/>
)}
</AlertComponent>
) : (
<div
className={cx('message')}
dangerouslySetInnerHTML={{
__html: sanitize(result.preview || ''),
}}
/>
)}
</>
) : (
<LoadingPlaceholder text="Loading..." />
);

View file

@ -0,0 +1,14 @@
.template-block-title {
padding: 16px;
align-items: baseline;
}
.template-block-list {
width: 30%;
height: 100%;
}
.alert-group-payload-view {
background-color: var(--primary-background);
border: none;
}

View file

@ -0,0 +1,187 @@
import React, { useEffect, useState } from 'react';
import { Button, HorizontalGroup, Tooltip, Icon, VerticalGroup, IconButton, Badge } 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 Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
const cx = cn.bind(styles);
interface TemplatesAlertGroupsListProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
onSelectAlertGroup?: (alertGroup: Alert) => void;
onEditPayload?: (payload: string) => void;
}
const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
const { alertReceiveChannelId, onEditPayload, onSelectAlertGroup } = props;
const store = useStore();
const [alertGroupsList, setAlertGroupsList] = useState(undefined);
const [selectedAlertPayload, setSelectedAlertPayload] = useState<string>(undefined);
const [selectedAlertName, setSelectedAlertName] = useState<string>(undefined);
const [isEditMode, setIsEditMode] = useState(false);
useEffect(() => {
store.alertGroupStore
.getAlertGroupsForIntegration(alertReceiveChannelId)
.then((result) => setAlertGroupsList(result.slice(0, 30)));
}, []);
const getChangeHandler = () => {
return debounce((value: string) => {
onEditPayload(value);
}, 1000);
};
const returnToListView = () => {
setIsEditMode(false);
setSelectedAlertPayload(undefined);
onEditPayload(null);
};
const getAlertGroupPayload = async (id) => {
const groupedAlert = await store.alertGroupStore.getAlertsFromGroup(id);
const currentIncidentRawResponse = await store.alertGroupStore.getPayloadForIncident(groupedAlert?.alerts[0]?.id);
setSelectedAlertName(getAlertGroupName(groupedAlert));
setSelectedAlertPayload(currentIncidentRawResponse?.raw_request_data);
onSelectAlertGroup(groupedAlert);
onEditPayload(JSON.stringify(currentIncidentRawResponse?.raw_request_data));
};
const getAlertGroupName = (alertGroup: Alert) => {
return alertGroup.inside_organization_number
? `#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}`
: alertGroup.render_for_web.title;
};
return (
<div className={cx('template-block-list')}>
{selectedAlertPayload ? (
<>
{isEditMode ? (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Edit {selectedAlertName}</Text>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
<MonacoJinja2Editor
value={JSON.stringify(selectedAlertPayload, null, 4)}
data={undefined}
height={'100vh'}
onChange={getChangeHandler()}
showLineNumbers
/>
</div>
</>
) : (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>{selectedAlertName}</Text>
<HorizontalGroup>
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
<VerticalGroup>
<Badge style={{ margin: '16px' }} color="blue" text="Last alert payload" />
<SourceCode className={cx('alert-group-payload-view')} noMaxHeight>
{JSON.stringify(selectedAlertPayload, null, 4)}
</SourceCode>
</VerticalGroup>
</div>
</>
)}
</>
) : (
<>
{isEditMode ? (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<Text>Edit custom payload</Text>
<HorizontalGroup>
<IconButton name="times" onClick={() => returnToListView()} />
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
<MonacoJinja2Editor
value={null}
data={undefined}
height={'100vh'}
onChange={getChangeHandler()}
showLineNumbers
/>
</div>
</>
) : (
<>
<div className={cx('template-block-title')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Text>Recent Alert groups</Text>
<Tooltip content="Here will be information about alert groups">
<Icon name="info-circle" />
</Tooltip>
</HorizontalGroup>
<Button variant="secondary" fill="outline" onClick={() => setIsEditMode(true)} size="sm">
Use custom payload
</Button>
</HorizontalGroup>
</div>
<div className={cx('alert-groups-list')}>
{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={
<HorizontalGroup>
<Icon name="info-circle" />
<Text>
This integration did not receive any alerts. Use custom payload example to preview results.
</Text>
</HorizontalGroup>
}
/>
)}
</div>
</>
)}
</>
)}
</div>
);
};
export default TemplatesAlertGroupsList;

View file

@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { HorizontalGroup } from '@grafana/ui';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
interface UserDisplayProps {
id: User['pk'];
}
const UserDisplayWithAvatar = observer(({ id }: UserDisplayProps) => {
const { userStore } = useStore();
useEffect(() => {
if (!userStore.items[id]) {
userStore.updateItem(id);
}
}, [id]);
const user = userStore.items[id];
if (!user) {
return null;
}
return (
<HorizontalGroup spacing="xs">
<Avatar size="small" src={user.avatar}></Avatar>
<Text type="secondary">{user.email}</Text>
</HorizontalGroup>
);
});
export default UserDisplayWithAvatar;

View file

@ -10,7 +10,7 @@ export enum MaintenanceMode {
export interface AlertReceiveChannel {
id: string;
integration: number;
integration: string;
smile_code: string;
verbal_name: string;
author: User['pk'];
@ -23,6 +23,7 @@ export interface AlertReceiveChannel {
instructions: string;
demo_alert_enabled: boolean;
maintenance_mode?: MaintenanceMode;
maintenance_till?: string;
heartbeat: Heartbeat | null;
is_available_for_integration_heartbeat: boolean;
}
@ -31,5 +32,3 @@ export interface AlertReceiveChannelChoice {
display_name: string;
value: number;
}
export const MaintenanceIntegration = 24;

View file

@ -24,6 +24,7 @@ import {
export class AlertReceiveChannelStore extends BaseStore {
@observable.shallow
// searchResult: { count?: number; results?: Array<AlertReceiveChannel['id']> } = {};
searchResult: Array<AlertReceiveChannel['id']>;
@observable.shallow
@ -66,6 +67,15 @@ export class AlertReceiveChannelStore extends BaseStore {
return this.searchResult.map(
(alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
);
// return {
// count: this.searchResult.count,
// results:
// this.searchResult.results &&
// this.searchResult.results.map(
// (alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
// ),
// };
}
@action
@ -82,6 +92,27 @@ 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 });
@ -131,7 +162,7 @@ export class AlertReceiveChannelStore extends BaseStore {
}
@action
async updateChannelFilters(alertReceiveChannelId: AlertReceiveChannel['id']) {
async updateChannelFilters(alertReceiveChannelId: AlertReceiveChannel['id'], isOverwrite = false) {
const response = await makeRequest(`/channel_filters/`, {
params: { alert_receive_channel: alertReceiveChannelId },
});
@ -149,6 +180,13 @@ export class AlertReceiveChannelStore extends BaseStore {
...channelFilters,
};
if (isOverwrite) {
// This is needed because on Move Up/Down/Removal the store no longer reflects correct state
this.channelFilters = {
...channelFilters,
};
}
this.channelFilterIds = {
...this.channelFilterIds,
[alertReceiveChannelId]: response.map((channelFilter: ChannelFilter) => channelFilter.id),
@ -206,7 +244,7 @@ export class AlertReceiveChannelStore extends BaseStore {
await makeRequest(`/channel_filters/${channelFilterId}/move_to_position/?position=${newIndex}`, { method: 'PUT' });
this.updateChannelFilters(alertReceiveChannelId);
this.updateChannelFilters(alertReceiveChannelId, true);
}
@action
@ -224,7 +262,7 @@ export class AlertReceiveChannelStore extends BaseStore {
method: 'DELETE',
});
this.updateChannelFilters(channelFilter.alert_receive_channel);
this.updateChannelFilters(channelFilter.alert_receive_channel, true);
}
@action
@ -341,10 +379,10 @@ export class AlertReceiveChannelStore extends BaseStore {
await makeRequest(`/channel_filters/${id}/send_demo_alert/`, { method: 'POST' }).catch(showApiError);
}
async renderPreview(id: AlertReceiveChannel['id'], template_name: string, template_body: string) {
async renderPreview(id: AlertReceiveChannel['id'], template_name: string, template_body: string, payload: JSON) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body },
data: { template_name, template_body, payload },
});
}

View file

@ -10,7 +10,7 @@ export enum MaintenanceMode {
export interface AlertReceiveChannel {
id: string;
integration: number;
integration: any;
smile_code: string;
verbal_name: string;
description: string;

View file

@ -1,19 +1,35 @@
export interface AlertTemplatesDTO {
slack_title_template: string;
slack_title_template_is_default: boolean;
web_title_template: string;
web_title_template_is_default: boolean;
sms_title_template: string;
sms_title_template_is_default: boolean;
phone_call_title_template: string;
phone_call_title_template_is_default: boolean;
email_title_template: string;
email_title_template_is_default: boolean;
telegram_title_template: string;
telegram_title_template_is_default: boolean;
slack_message_template: string;
slack_message_template_is_default: boolean;
web_message_template: string;
web_message_template_is_default: boolean;
email_message_template: string;
email_message_template_is_default: boolean;
telegram_message_template: string;
telegram_message_template_is_default: boolean;
slack_image_url_template: string;
slack_image_url_template_is_default: boolean;
web_image_url_template: string;
web_image_url_template_is_default: boolean;
telegram_image_url_template: string;
telegram_image_url_template_is_default: boolean;
grouping_id_template: string;
grouping_id_template_is_default: boolean;
resolve_condition_template: string;
resolve_condition_template_is_default: boolean;
acknowledge_condition_template: string;
acknowledge_condition_template_is_default: boolean;
payload_example: string;
}

View file

@ -1,6 +1,7 @@
import { action, observable } from 'mobx';
import qs from 'query-string';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import BaseStore from 'models/base_store';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
@ -131,6 +132,17 @@ export class AlertGroupStore extends BaseStore {
return this.searchResult[query].map((id: Alert['pk']) => this.items[id]);
}
async getAlertGroupsForIntegration(integrationId: AlertReceiveChannel['id']) {
const { results } = await makeRequest(`${this.path}`, {
params: { integration: integrationId },
});
return results;
}
async getAlertsFromGroup(pk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}`, {});
}
@action
async updateSilenceOptions() {
this.silenceOptions = await makeRequest(`${this.path}silence_options/`, {});

View file

@ -54,6 +54,10 @@ export interface Alert {
is_restricted: boolean;
channel: Channel;
slack_permalink?: string;
permalinks: {
slack: string;
telegram: string;
};
declare_incident_link?: string;
related_users: User[];
render_after_resolve_report_json?: TimeLineItem[];

View file

@ -9,7 +9,6 @@ import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { MaintenanceIntegration } from 'models/alert_receive_channel';
import { Alert as AlertType, Alert, IncidentStatus } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
@ -188,35 +187,31 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const buttons = [];
if (incident.alert_receive_channel.integration !== MaintenanceIntegration) {
if (incident.status === IncidentStatus.Silenced) {
buttons.push(
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
<Button disabled={incident.loading || incident.is_restricted} variant="secondary" onClick={onUnsilence}>
Unsilence
</Button>
</WithPermissionControlTooltip>
);
} else if (incident.status !== IncidentStatus.Resolved) {
buttons.push(
<SilenceButtonCascader
className={cx('silence-button-inline')}
key="silence"
disabled={incident.loading || incident.is_restricted}
onSelect={onSilence}
/>
);
}
if (incident.status === IncidentStatus.Silenced) {
buttons.push(
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
<Button disabled={incident.loading || incident.is_restricted} variant="secondary" onClick={onUnsilence}>
Unsilence
</Button>
</WithPermissionControlTooltip>
);
} else if (incident.status !== IncidentStatus.Resolved) {
buttons.push(
<SilenceButtonCascader
className={cx('silence-button-inline')}
key="silence"
disabled={incident.loading || incident.is_restricted}
onSelect={onSilence}
/>
);
}
if (!incident.resolved && !incident.acknowledged) {
buttons.push(acknowledgeButton, resolveButton);
} else if (!incident.resolved) {
buttons.push(unacknowledgeButton, resolveButton);
} else {
buttons.push(unresolveButton);
}
if (!incident.resolved && !incident.acknowledged) {
buttons.push(acknowledgeButton, resolveButton);
} else if (!incident.resolved) {
buttons.push(resolveButton);
buttons.push(unacknowledgeButton, resolveButton);
} else {
buttons.push(unresolveButton);
}
return <HorizontalGroup justify="flex-end">{buttons}</HorizontalGroup>;

View file

@ -37,7 +37,7 @@ import Text from 'components/Text/Text';
import AttachIncidentForm from 'containers/AttachIncidentForm/AttachIncidentForm';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForEdit, prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import IntegrationSettings from 'containers/IntegrationSettings/IntegrationSettings';
// import IntegrationSettings from 'containers/IntegrationSettings/IntegrationSettings';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import {
@ -122,7 +122,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
const { isNotFoundError, isWrongTeamError } = errorData;
const { alertReceiveChannelStore } = store;
// const { alertReceiveChannelStore } = store;
const { alerts } = store.alertGroupStore;
const incident = alerts.get(id);
@ -176,22 +176,45 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</div>
</div>
{showIntegrationSettings && (
<IntegrationSettings
alertGroupId={incident.pk}
onUpdate={() => {
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
}}
onUpdateTemplates={() => {
store.alertGroupStore.getAlert(id);
}}
startTab={IntegrationSettingsTab.Templates}
id={incident.alert_receive_channel.id}
onHide={() =>
// <IntegrationSettings
// alertGroupId={incident.pk}
// onUpdate={() => {
// alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
// }}
// onUpdateTemplates={() => {
// store.alertGroupStore.getAlert(id);
// }}
// startTab={IntegrationSettingsTab.Templates}
// id={incident.alert_receive_channel.id}
// onHide={() =>
// this.setState({
// showIntegrationSettings: undefined,
// })
// }
// />
<Modal
isOpen
title="Edit template"
onDismiss={() =>
this.setState({
showIntegrationSettings: undefined,
})
}
/>
>
<Text>
Please go to{' '}
<PluginLink
query={{
page: 'integrations',
id: incident.alert_receive_channel.id,
tab: IntegrationSettingsTab.Templates,
}}
>
Integrations
</PluginLink>{' '}
to edit this template
</Text>
</Modal>
)}
{showAttachIncidentForm && (
<AttachIncidentForm

View file

@ -29,7 +29,7 @@ const getIncidentTagColor = (alert: Alert) => {
return getVar('--tag-secondary');
};
function ListMenu({ alert, openMenu }: { alert: Alert; openMenu: React.MouseEventHandler<HTMLElement> }) {
function IncidentStatusTag({ alert, openMenu }: { alert: Alert; openMenu: React.MouseEventHandler<HTMLElement> }) {
const forwardedRef = useRef<HTMLSpanElement>();
return (
@ -109,7 +109,7 @@ export const IncidentDropdown: FC<{
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
@ -149,7 +149,7 @@ export const IncidentDropdown: FC<{
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
@ -207,7 +207,7 @@ export const IncidentDropdown: FC<{
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
}
@ -260,7 +260,7 @@ export const IncidentDropdown: FC<{
</div>
)}
>
{({ openMenu }) => <ListMenu alert={alert} openMenu={openMenu} />}
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
</WithContextMenu>
);
};

View file

@ -50,6 +50,15 @@ export const pages: { [id: string]: PageDefinition } = [
text: 'Integrations',
action: UserActions.IntegrationsRead,
},
{
icon: 'plug',
id: 'integrations_2',
text: 'Integrations 2',
path: getPath('integrations_2'),
hideFromBreadcrumbs: true,
hideFromTabs: true,
action: UserActions.IntegrationsRead,
},
{
icon: 'list-ul',
id: 'escalations',
@ -96,7 +105,6 @@ export const pages: { [id: string]: PageDefinition } = [
hideFromTabs: isTopNavbar(),
action: UserActions.ChatOpsRead,
},
{
icon: 'link',
id: 'outgoing_webhooks_2',
@ -180,6 +188,8 @@ export const ROUTES = {
'alert-group': ['alert-groups/:id'],
users: ['users', 'users/:id'],
integrations: ['integrations', 'integrations/:id'],
integrations_2: ['integrations_2'],
integration_2: ['integrations_2/:id'],
escalations: ['escalations', 'escalations/:id'],
schedules: ['schedules'],
schedule: ['schedules/:id'],

View file

@ -0,0 +1,3 @@
.spacing {
margin-bottom: 12px;
}

View file

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { ConfirmModal, HorizontalGroup, Icon } 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 { 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';
import IntegrationHelper from './Integration2.helper';
import IntegrationBlock from './IntegrationBlock';
const cx = cn.bind(styles);
interface CollapsedIntegrationRouteDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
}
const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDisplayProps> = observer(
({ channelFilterId, alertReceiveChannelId, routeIndex }) => {
const { escalationChainStore, alertReceiveChannelStore } = useStore();
const [routeIdForDeletion, setRouteIdForDeletion] = useState<ChannelFilter['id']>(undefined);
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
if (!channelFilter) {
return null;
}
const escalationChain = escalationChainStore.items[channelFilter.escalation_chain];
return (
<>
<IntegrationBlock
hasCollapsedBorder
key={channelFilterId}
heading={
<HorizontalGroup justify={'space-between'}>
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-primary')}>
{IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)}
</Tag>
{channelFilter.filtering_term && (
<Text type="link">{IntegrationHelper.truncateLine(channelFilter.filtering_term)}</Text>
)}
</HorizontalGroup>
<HorizontalGroup>
<RouteButtonsDisplay
alertReceiveChannelId={alertReceiveChannelId}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
setRouteIdForDeletion={() => setRouteIdForDeletion(channelFilterId)}
/>
</HorizontalGroup>
</HorizontalGroup>
}
content={
<div className={cx('spacing')}>
<HorizontalGroup>
{channelFilter.slack_channel?.display_name && (
<HorizontalGroup>
<Text type="secondary">Publish to ChatOps</Text>
<Icon name="slack" />
<Text type="primary" strong>
{channelFilter.slack_channel.display_name}
</Text>
</HorizontalGroup>
)}
<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>
</HorizontalGroup>
</HorizontalGroup>
</div>
}
/>
{routeIdForDeletion && (
<ConfirmModal
isOpen
title="Delete route?"
body="Are you sure you want to delete this route?"
confirmText="Delete"
icon="exclamation-triangle"
onConfirm={onRouteDeleteConfirm}
onDismiss={() => setRouteIdForDeletion(undefined)}
/>
)}
</>
);
async function onRouteDeleteConfirm() {
setRouteIdForDeletion(undefined);
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
}
}
);
export default CollapsedIntegrationRouteDisplay;

View file

@ -0,0 +1,8 @@
.input {
&--short {
width: 500px;
}
&--long {
width: 700px;
}
}

View file

@ -0,0 +1,293 @@
import React, { useReducer } from 'react';
import { SelectableValue } from '@grafana/data';
import { Button, HorizontalGroup, InlineLabel, VerticalGroup, Icon, Tooltip, ConfirmModal } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import PluginLink from 'components/PluginLink/PluginLink';
import Tag from 'components/Tag/Tag';
import Text from 'components/Text/Text';
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 { 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';
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_OPTIONS } from './Integration2.config';
import IntegrationHelper from './Integration2.helper';
import IntegrationBlock from './IntegrationBlock';
import IntegrationBlockItem from './IntegrationBlockItem';
const cx = cn.bind(styles);
interface ExpandedIntegrationRouteDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
templates: AlertTemplatesDTO[];
openEditTemplateModal: (templateName: string | string[]) => void;
}
interface ExpandedIntegrationRouteDisplayState {
isEscalationCollapsed: boolean;
isRefreshingEscalationChains: boolean;
routeIdForDeletion: string;
}
const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayProps> = observer(
({ alertReceiveChannelId, channelFilterId, templates, routeIndex, openEditTemplateModal }) => {
const { escalationPolicyStore, escalationChainStore, alertReceiveChannelStore, grafanaTeamStore } = useStore();
const hasChatOpsConnectors = false;
const [{ isEscalationCollapsed, isRefreshingEscalationChains, routeIdForDeletion }, setState] = useReducer(
(state: ExpandedIntegrationRouteDisplayState, newState: Partial<ExpandedIntegrationRouteDisplayState>) => ({
...state,
...newState,
}),
{
isEscalationCollapsed: true,
isRefreshingEscalationChains: false,
routeIdForDeletion: undefined,
}
);
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters);
if (!channelFilter) {
return null;
}
return (
<>
<IntegrationBlock
hasCollapsedBorder
key={channelFilterId}
heading={
<HorizontalGroup justify={'space-between'}>
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-primary')}>
{IntegrationHelper.getRouteConditionWording(alertReceiveChannelStore.channelFilters, routeIndex)}
</Tag>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<RouteButtonsDisplay
alertReceiveChannelId={alertReceiveChannelId}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
setRouteIdForDeletion={() => setState({ routeIdForDeletion: channelFilterId })}
/>
</HorizontalGroup>
</HorizontalGroup>
}
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={undefined} />
<Button variant="secondary" size="md" onClick={() => openEditTemplateModal('routing')}>
<Text type="link">Help</Text>
<Icon name="angle-down" size="sm" />
</Button>
</HorizontalGroup>
</IntegrationBlockItem>
)}
{routeIndex !== channelFiltersTotal.length - 1 && (
<IntegrationBlockItem>
<VerticalGroup>
<Text type="secondary">
If the Routing template evaluates to True, the alert will be grouped with the Grouping template
and proceed to the following steps
</Text>
</VerticalGroup>
</IntegrationBlockItem>
)}
{hasChatOpsConnectors && (
<IntegrationBlockItem>
<VerticalGroup spacing="md">
<Text type="primary">Publish to ChatOps</Text>
<ChatOpsConnectors channelFilterId={channelFilterId} />
</VerticalGroup>
</IntegrationBlockItem>
)}
<IntegrationBlockItem>
<VerticalGroup>
<HorizontalGroup spacing={'xs'}>
<InlineLabel width={20}>Escalation chain</InlineLabel>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<GSelect
showSearch
modelName="escalationChainStore"
isLoading={isRefreshingEscalationChains}
displayField="name"
placeholder="Select Escalation Chain"
className={cx('select', 'control')}
value={channelFilter.escalation_chain}
onChange={onEscalationChainChange}
showWarningIfEmptyValue={true}
width={'auto'}
icon={'list-ul'}
getOptionLabel={(item: SelectableValue) => {
return (
<>
<Text>{item.label} </Text>
<TeamName
team={grafanaTeamStore.items[escalationChainStore.items[item.value].team]}
size="small"
/>
</>
);
}}
/>
</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>
<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>
</HorizontalGroup>
{isEscalationCollapsed && (
<ReadOnlyEscalationChain escalationChainId={channelFilter.escalation_chain} />
)}
</VerticalGroup>
</IntegrationBlockItem>
</VerticalGroup>
}
/>
{routeIdForDeletion && (
<ConfirmModal
isOpen
title="Delete route?"
body="Are you sure you want to delete this route?"
confirmText="Delete"
icon="exclamation-triangle"
onConfirm={onRouteDeleteConfirm}
onDismiss={() => setState({ routeIdForDeletion: undefined })}
/>
)}
</>
);
async function onRouteDeleteConfirm() {
setState({ routeIdForDeletion: undefined });
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
}
function onEscalationChainChange(value: string) {
alertReceiveChannelStore
.saveChannelFilter(channelFilterId, {
escalation_chain: value,
})
.then(() => {
escalationChainStore.updateItems(); // to update number_of_integrations and number_of_routes
escalationPolicyStore.updateEscalationPolicies(value);
});
}
async function onEscalationChainsRefresh() {
setState({ isRefreshingEscalationChains: true });
await escalationChainStore.updateItems();
setState({ isRefreshingEscalationChains: false });
}
}
);
const ReadOnlyEscalationChain: React.FC<{ escalationChainId: string }> = ({ escalationChainId }) => {
return <EscalationChainSteps isDisabled id={escalationChainId} />;
};
interface RouteButtonsDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
setRouteIdForDeletion(): void;
}
export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
alertReceiveChannelId,
channelFilterId,
routeIndex,
setRouteIdForDeletion,
}) => {
const { alertReceiveChannelStore } = useStore();
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters);
return (
<HorizontalGroup>
{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'} />
</Tooltip>
</WithPermissionControlTooltip>
)}
{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'} />
</Tooltip>
</WithPermissionControlTooltip>
)}
{!channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Tooltip placement="top" content={'Delete'}>
<Button variant={'secondary'} icon={'trash-alt'} size={'xs'} onClick={setRouteIdForDeletion} />
</Tooltip>
</WithPermissionControlTooltip>
)}
</HorizontalGroup>
);
function onRouteMoveDown() {
alertReceiveChannelStore.moveChannelFilterToPosition(alertReceiveChannelId, routeIndex, routeIndex + 1);
}
function onRouteMoveUp() {
alertReceiveChannelStore.moveChannelFilterToPosition(alertReceiveChannelId, routeIndex, routeIndex - 1);
}
};
export default ExpandedIntegrationRouteDisplay;

View file

@ -0,0 +1,143 @@
import { KeyValuePair } from 'utils';
export const TEXTAREA_ROWS_COUNT = 4;
export const MAX_CHARACTERS_COUNT = 50;
export const MONACO_OPTIONS = {
renderLineHighlight: false,
readOnly: true,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
verticalScrollbarSize: 0,
handleMouseWheel: false,
},
hideCursorInOverviewRuler: true,
minimap: { enabled: false },
cursorStyle: {
display: 'none',
},
};
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_INPUT_HEIGHT_SMALL = '32px';
export const MONACO_INPUT_HEIGHT_TALL = '120px';
const TemplateOptions = {
SourceLink: new KeyValuePair('source_link_template', 'Source Link'),
Autoacknowledge: new KeyValuePair('acknowledge_condition_template', 'Autoacknowledge'),
Phone: new KeyValuePair('phone_call_title_template', 'Phone'),
SMS: new KeyValuePair('sms_title_template', 'SMS'),
SlackTitle: new KeyValuePair('slack_title_template', 'Title'),
SlackMessage: new KeyValuePair('slack_message_template', 'Message'),
SlackImage: new KeyValuePair('slack_image_url_template', 'Image'),
EmailTitle: new KeyValuePair('email_title_template', 'Title'),
EmailMessage: new KeyValuePair('email_message_template', 'Message'),
TelegramTitle: new KeyValuePair('telegram_title_template', 'Title'),
TelegramMessage: new KeyValuePair('telegram_message_template', 'Message'),
TelegramImage: new KeyValuePair('telegram_image_url_template', 'Image'),
/*Should it be in Oncallprivate repo? (All MsTeams)*/
MSTeamsTitle: new KeyValuePair('MSTeams Title', 'Title'),
MSTeamsMessage: new KeyValuePair('MSTeams Message', 'Message'),
MSTeamsImage: new KeyValuePair('MSTeams Image', 'Image'),
Email: new KeyValuePair('Email', 'Email'),
Slack: new KeyValuePair('Slack', 'Slack'),
MSTeams: new KeyValuePair('Microsoft Teams', 'Microsoft Teams'),
Telegram: new KeyValuePair('Telegram', 'Telegram'),
};
export const INTEGRATION_TEMPLATES_LIST = [
{
label: TemplateOptions.SourceLink.value,
value: TemplateOptions.SourceLink.key,
},
{
label: TemplateOptions.Autoacknowledge.value,
value: TemplateOptions.Autoacknowledge.key,
},
{
label: TemplateOptions.Phone.value,
value: TemplateOptions.Phone.key,
},
{
label: TemplateOptions.SMS.value,
value: TemplateOptions.SMS.key,
},
{
label: TemplateOptions.Email.value,
value: TemplateOptions.Email.key,
children: [
{
label: TemplateOptions.EmailTitle.value,
value: TemplateOptions.EmailTitle.key,
},
{
label: TemplateOptions.EmailMessage.value,
value: TemplateOptions.EmailMessage.key,
},
],
},
{
label: TemplateOptions.Slack.value,
value: TemplateOptions.Slack.key,
children: [
{
label: TemplateOptions.SlackTitle.value,
value: TemplateOptions.SlackTitle.key,
},
{
label: TemplateOptions.SlackMessage.value,
value: TemplateOptions.SlackMessage.key,
},
{
label: TemplateOptions.SlackImage.value,
value: TemplateOptions.SlackImage.key,
},
],
},
{
label: TemplateOptions.MSTeams.value,
value: TemplateOptions.MSTeams.key,
children: [
{
label: TemplateOptions.MSTeamsTitle.value,
value: TemplateOptions.MSTeamsTitle.key,
},
{
label: TemplateOptions.MSTeamsMessage.value,
value: TemplateOptions.MSTeamsMessage.key,
},
{
label: TemplateOptions.MSTeamsImage.value,
value: TemplateOptions.MSTeamsImage.key,
},
],
},
{
label: TemplateOptions.Telegram.value,
value: TemplateOptions.Telegram.key,
children: [
{
label: TemplateOptions.TelegramTitle.value,
value: TemplateOptions.TelegramTitle.key,
},
{
label: TemplateOptions.TelegramMessage.value,
value: TemplateOptions.TelegramMessage.key,
},
{
label: TemplateOptions.TelegramImage.value,
value: TemplateOptions.TelegramImage.key,
},
],
},
];

View file

@ -0,0 +1,41 @@
import { ChannelFilter } from 'models/channel_filter';
import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './Integration2.config';
const IntegrationHelper = {
getFilteredTemplate: (template: string, isTextArea: boolean): string => {
if (!template) {
return '';
}
const lines = template.split('\n');
if (isTextArea) {
return lines
.slice(0, TEXTAREA_ROWS_COUNT + 1)
.map((line) => IntegrationHelper.truncateLine(line))
.join('\n');
}
return IntegrationHelper.truncateLine(lines[0]);
},
truncateLine: (line: string, maxCharacterCount: number = MAX_CHARACTERS_COUNT): string => {
if (!line || !line.trim()) {
return '';
}
const slice = line.substring(0, maxCharacterCount);
return slice.length === line.length ? slice : `${slice} ...`;
},
getRouteConditionWording(channelFilters: { [id: string]: ChannelFilter }, routeIndex: number) {
const totalCount = Object.keys(channelFilters).length;
if (routeIndex === totalCount - 1) {
return 'Default';
}
return routeIndex ? 'Else' : 'If';
},
};
export default IntegrationHelper;

View file

@ -0,0 +1,105 @@
$FLEX-GAP: 4px;
$MARGIN: 12px;
$ITEMS-MARGIN: 24px;
.integration__heading-container {
margin-bottom: $ITEMS-MARGIN;
}
.integration__heading {
display: flex;
justify-content: space-between;
flex-direction: row;
width: 100%;
}
.integration__actions {
display: flex;
gap: $FLEX-GAP;
}
.integration__actionsList {
display: flex;
flex-direction: column;
width: 160px;
border-radius: 2px;
}
.integration__actionItem {
padding: 8px;
display: flex;
align-items: center;
flex-direction: row;
flex-shrink: 0;
white-space: nowrap;
border-left: 2px solid transparent;
cursor: pointer;
min-width: 84px;
display: flex;
gap: 8px;
flex-direction: row;
&:hover {
background: var(--gray-9);
}
}
.integration__description {
display: block;
margin-bottom: $MARGIN;
}
.hamburger-menu {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
justify-content: center;
background-color: rgba(204, 204, 220, 0.16);
color: var(--secondary-background);
border: 1px solid transparent;
height: 32px;
width: 30px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}
.integration__counter {
font-size: 12px;
}
.integration__countersBadge {
line-height: 16px;
padding: 3px 4px;
}
.loadingPlaceholder {
margin-bottom: 0;
}
.customise-button button {
padding: 15px;
}
.routesSection {
padding-top: 20px;
}
.input {
&--short {
width: 500px;
}
&--long {
width: 700px;
}
}
.how-to-connect__container {
display: flex;
align-items: center;
gap: 8px;
}
.tag,
.how-to-connect__tag {
height: 25px;
}

View file

@ -0,0 +1,670 @@
import React, { useRef } from 'react';
import {
Badge,
Button,
HorizontalGroup,
VerticalGroup,
Icon,
LoadingPlaceholder,
Tooltip,
Modal,
CascaderOption,
} 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 { TemplateForEdit, templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import CounterBadge from 'components/CounterBadge/CounterBadge';
import IntegrationCollapsibleTreeView, {
IntegrationCollapsibleItem,
} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import IntegrationMaskedInputField from 'components/IntegrationMaskedInputField/IntegrationMaskedInputField';
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 WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
import TeamName from 'containers/TeamName/TeamName';
import UserDisplayWithAvatar from 'containers/UserDisplay/UserDisplayWithAvatar';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { ChannelFilter } from 'models/channel_filter';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { openNotification, openErrorNotification } from 'utils';
import { getVar } from 'utils/DOM';
import { UserActions } from 'utils/authorization';
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 IntegrationHelper from './Integration2.helper';
import styles from './Integration2.module.scss';
import IntegrationBlock from './IntegrationBlock';
import IntegrationTemplateList from './IntegrationTemplatesList';
// import { toJS } from 'mobx';
const cx = cn.bind(styles);
interface Integration2Props extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface Integration2State extends PageBaseState {
isDemoModalOpen: boolean;
isEditTemplateModalOpen: boolean;
selectedTemplate: TemplateForEdit;
}
// This can be further improved by using a ref instead
const ACTIONS_LIST_WIDTH = 160;
const ACTIONS_LIST_BORDER = 2;
@observer
class Integration2 extends React.Component<Integration2Props, Integration2State> {
constructor(props: Integration2Props) {
super(props);
this.state = {
errorData: initErrorDataState(),
isDemoModalOpen: false,
isEditTemplateModalOpen: false,
selectedTemplate: undefined,
};
}
async componentDidMount() {
const {
match: {
params: { id },
},
} = this.props;
const {
store: { alertReceiveChannelStore },
} = this.props;
await Promise.all([this.loadIntegration(), alertReceiveChannelStore.updateTemplates(id)]);
}
render() {
const { errorData, isDemoModalOpen, isEditTemplateModalOpen, selectedTemplate } = this.state;
const {
store: { alertReceiveChannelStore, grafanaTeamStore },
match: {
params: { id },
},
} = this.props;
const { isNotFoundError, isWrongTeamError } = errorData;
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id];
const templates = alertReceiveChannelStore.templates[id];
if ((!alertReceiveChannel && !isNotFoundError && !isWrongTeamError) || !channelFilterIds || !templates) {
return (
<div className={cx('root')}>
<LoadingPlaceholder text="Loading Integration..." />
</div>
);
}
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="integration" pageName="Integration">
{() => (
<div className={cx('root')}>
<div className={cx('integration__heading-container')}>
<div className={cx('integration__heading')}>
<h1 className={cx('integration__name')}>
<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>
</div>
{alertReceiveChannel.description && (
<Text type="secondary" className={cx('integration__description')}>
{alertReceiveChannel.description}
</Text>
)}
<HorizontalGroup>
{alertReceiveChannelCounter && (
<Tooltip
placement="bottom-start"
content={
alertReceiveChannelCounter?.alerts_count +
' alert' +
(alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's') +
' in ' +
alertReceiveChannelCounter?.alert_groups_count +
' alert group' +
(alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's')
}
>
{/* <span> is needed to be child, otherwise Tooltip won't render */}
<span>
<PluginLink
query={{ page: 'alert-groups', integration: alertReceiveChannel.id }}
className={cx('integration__counter')}
>
<Badge
text={
alertReceiveChannelCounter?.alerts_count +
'/' +
alertReceiveChannelCounter?.alert_groups_count
}
className={cx('integration__countersBadge')}
color={'blue'}
/>
</PluginLink>
</span>
</Tooltip>
)}
<CounterBadge
borderType="success"
icon="link"
count={channelFilterIds.length}
tooltipTitle={`${channelFilterIds.length} Routes`}
tooltipContent={undefined}
/>
<HorizontalGroup spacing="xs">
<Text type="secondary">Type:</Text>
<HorizontalGroup spacing="none">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary" size="small">
{integration?.display_name}
</Text>
</HorizontalGroup>
</HorizontalGroup>
<HorizontalGroup spacing="xs">
<Text type="secondary">Team:</Text>
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} size="small" />
</HorizontalGroup>
<HorizontalGroup spacing="xs">
<Text type="secondary">Created by:</Text>
<UserDisplayWithAvatar id={alertReceiveChannel.author as any}></UserDisplayWithAvatar>
</HorizontalGroup>
</HorizontalGroup>
</div>
<IntegrationCollapsibleTreeView
configElements={[
{
isCollapsible: false,
customIcon: 'plug',
collapsedView: null,
expandedView: <HowToConnectComponent id={id} />,
},
{
isCollapsible: true,
collapsedView: (
<IntegrationBlock
hasCollapsedBorder
heading={
<HorizontalGroup spacing={'md'}>
<Tag color={getVar('--tag-secondary')} className={cx('tag')}>
<Text type="primary" size="small">
Templates
</Text>
</Tag>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Grouping:</Text>
<Text type="link">
{IntegrationHelper.getFilteredTemplate(templates['grouping_id_template'] || '', false)}
</Text>
</HorizontalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type="secondary">Visualisation:</Text>
<Text type="primary">Multiple</Text>
</HorizontalGroup>
</HorizontalGroup>
}
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}
/>
}
/>
),
},
{
customIcon: 'plus',
isCollapsible: false,
collapsedView: null,
expandedView: (
<div className={cx('routesSection')}>
<VerticalGroup spacing="md">
<Text type={'primary'}>Routes</Text>
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Button variant={'primary'} onClick={() => this.openEditTemplateModal('routing')}>
Add route
</Button>
</WithPermissionControlTooltip>
</VerticalGroup>
</div>
),
},
this.renderRoutesFn(),
]}
/>
<IntegrationSendDemoPayloadModal
alertReceiveChannel={alertReceiveChannel}
isOpen={isDemoModalOpen}
onHideOrCancel={() => this.setState({ isDemoModalOpen: false })}
/>
{isEditTemplateModalOpen && (
<IntegrationTemplate
id={id}
onHide={() => {
this.setState({
isEditTemplateModalOpen: undefined,
});
}}
onUpdateTemplates={this.onUpdateTemplatesCallback}
onUpdateRoute={this.onUpdateRoutesCallback}
template={selectedTemplate}
templateBody={templates[selectedTemplate?.name]}
/>
)}
</div>
)}
</PageErrorHandlingWrapper>
);
}
renderRoutesFn = (): IntegrationCollapsibleItem[] => {
const {
store: { alertReceiveChannelStore },
match: {
params: { id },
},
} = this.props;
const templates = alertReceiveChannelStore.templates[id];
const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id];
return channelFilterIds.map((channelFilterId: ChannelFilter['id'], routeIndex: number) => ({
isCollapsible: true,
collapsedView: (
<CollapsedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
/>
),
expandedView: (
<ExpandedIntegrationRouteDisplay
alertReceiveChannelId={id}
channelFilterId={channelFilterId}
routeIndex={routeIndex}
templates={templates}
openEditTemplateModal={this.openEditTemplateModal}
/>
),
}));
};
handleSlackChannelChange = () => {};
onUpdateRoutesCallback = ({ routing }: { routing: string }) => {
const { alertReceiveChannelStore, escalationPolicyStore } = this.props.store;
const {
params: { id },
} = this.props.match;
alertReceiveChannelStore
.createChannelFilter({
order: 0,
alert_receive_channel: id,
filtering_term: routing,
// 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);
}
});
};
onUpdateTemplatesCallback = (data) => {
const {
store,
match: {
params: { id },
},
} = this.props;
store.alertReceiveChannelStore
.saveTemplates(id, data)
.then(() => {
openNotification('The Alert templates have been updated');
})
.catch((err) => {
if (err.response?.data?.length > 0) {
openErrorNotification(err.response.data);
} else {
openErrorNotification(err.message);
}
});
};
getTemplatesList = (): CascaderOption[] => INTEGRATION_TEMPLATES_LIST;
openEditTemplateModal = (templateName) => {
this.setState({ isEditTemplateModalOpen: true });
this.setState({ selectedTemplate: templateForEdit[templateName] });
};
onRemovalFn = (id: AlertReceiveChannel['id']) => {
const {
store: { alertReceiveChannelStore },
history,
} = this.props;
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 },
match: {
params: { id },
},
} = this.props;
const promises = [];
if (!alertReceiveChannelStore.items[id]) {
// See what happens if the request fails
promises.push(alertReceiveChannelStore.loadItem(id));
}
if (!alertReceiveChannelStore.counters?.length) {
promises.push(alertReceiveChannelStore.updateCounters());
}
if (!alertReceiveChannelStore.channelFilterIds[id]) {
promises.push(await alertReceiveChannelStore.updateChannelFilters(id));
}
await Promise.all(promises);
}
}
const DemoNotification: React.FC = () => {
return (
<div>
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'alert-groups' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
</div>
);
};
const HamburgerMenu: React.FC<{ openMenu: React.MouseEventHandler<HTMLElement> }> = ({ openMenu }) => {
const ref = useRef<HTMLDivElement>();
return (
<div
ref={ref}
className={cx('hamburger-menu')}
onClick={() => {
const boundingRect = ref.current.getBoundingClientRect();
openMenu({
pageX: boundingRect.right - ACTIONS_LIST_WIDTH + ACTIONS_LIST_BORDER * 2,
pageY: boundingRect.top + boundingRect.height,
} as any);
}}
>
<Icon size="sm" name="ellipsis-v" />
</div>
);
};
interface IntegrationSendDemoPayloadModalProps {
isOpen: boolean;
alertReceiveChannel: AlertReceiveChannel;
onHideOrCancel: () => void;
}
const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalProps> = ({
alertReceiveChannel,
isOpen,
onHideOrCancel,
}) => {
const { alertReceiveChannelStore } = useStore();
return (
<Modal
closeOnEscape
isOpen={isOpen}
onDismiss={onHideOrCancel}
title={`Send demo alert to ${alertReceiveChannel.verbal_name}`}
>
<VerticalGroup>
<HorizontalGroup spacing={'xs'}>
<Text type={'secondary'}>Alert Payload</Text>
<Tooltip content={'TODO'} placement={'top-start'}>
<Icon name={'info-circle'} />
</Tooltip>
</HorizontalGroup>
<SourceCode showCopyToClipboard={false}>{getDemoAlertJSON()}</SourceCode>
<HorizontalGroup justify={'flex-end'} spacing={'md'}>
<Button variant={'secondary'} onClick={onHideOrCancel}>
Cancel
</Button>
<CopyToClipboard text={getCurlText()} onCopy={() => openNotification('CURL copied!')}>
<Button variant={'secondary'}>Copy as CURL</Button>
</CopyToClipboard>
<Button variant={'primary'} onClick={sendDemoAlert}>
Send Alert
</Button>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
function sendDemoAlert() {
alertReceiveChannelStore.sendDemoAlert(alertReceiveChannel.id).then(() => {
alertReceiveChannelStore.updateCounters();
openNotification(<DemoNotification />);
onHideOrCancel();
});
}
function getCurlText() {
// TODO add this
return `curl -X POST [URL]
-H "Content-Type: application/json"
-d "[JSON data]"`;
}
function getDemoAlertJSON() {
return JSON.stringify(INTEGRATION_DEMO_PAYLOAD, null, 4);
}
};
const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id }) => {
const { alertReceiveChannelStore } = useStore();
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const isAlertManager = alertReceiveChannel.integration === DATASOURCE_ALERTING;
const hasAlerts = !!alertReceiveChannelCounter?.alerts_count;
return (
<IntegrationBlock
hasCollapsedBorder={false}
heading={
<div className={cx('how-to-connect__container')}>
<Tag color={getVar('--tag-secondary')} className={cx('how-to-connect__tag')}>
<Text type="primary" size="small">
HTTP Endpoint
</Text>
</Tag>
<IntegrationMaskedInputField value={alertReceiveChannelStore.items[id].integration_url} />
<a href="https://grafana.com/docs/oncall/latest/integrations/" target="_blank" rel="noreferrer">
<Text type="link" size="small" onClick={openHowToConnect}>
<HorizontalGroup>
How to connect
<Icon name="external-link-alt" />
</HorizontalGroup>
</Text>
</a>
</div>
}
content={isAlertManager || !hasAlerts ? renderContent() : null}
/>
);
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')} />
<Text type={'primary'}>No alerts yet; try to send a demo alert</Text>
</HorizontalGroup>
)}
{isAlertManager && (
<HorizontalGroup spacing={'xs'}>
<Icon name="list-ui-alt" size="md" />
<a href="/alerting/notifications" target="_blank">
<Text type={'link'}>Contact Point</Text>
</a>
<Text type={'secondary'}>and</Text>
<a href="/alerting/routes" target="_blank">
<Text type={'link'}>Notification Policy</Text>
</a>
<Text type={'secondary'}>created in Grafana Alerting</Text>
</HorizontalGroup>
)}
</VerticalGroup>
</div>
);
}
};
export default withRouter(withMobXProviderContext(Integration2));

View file

@ -0,0 +1,20 @@
.integrationBlock__heading,
.integrationBlock__content {
padding: 16px;
}
.integrationBlock__heading {
background-color: var(--background-secondary);
border: none;
}
.integrationBlock__content {
background: var(--background-primary);
border: var(--border-weak);
&--collapsedBorder {
border-left: none;
padding-left: 0;
padding-bottom: 0;
}
}

View file

@ -0,0 +1,36 @@
import React from 'react';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import styles from './IntegrationBlock.module.scss';
const cx = cn.bind(styles);
interface IntegrationBlockProps {
hasCollapsedBorder: boolean;
heading: React.ReactNode;
content: React.ReactNode;
}
const IntegrationBlock: React.FC<IntegrationBlockProps> = ({ heading, content, hasCollapsedBorder }) => {
return (
<div className={cx('integrationBlock')}>
<Block bordered shadowed className={cx('integrationBlock__heading')}>
{heading}
</Block>
{content && (
<div
className={cx('integrationBlock__content', {
'integrationBlock__content--collapsedBorder': hasCollapsedBorder,
})}
>
{content}
</div>
)}
</div>
);
};
export default IntegrationBlock;

View file

@ -0,0 +1,16 @@
.blockItem {
display: flex;
flex-direction: row;
margin-bottom: 12px;
&__content {
padding-top: 12px;
padding-bottom: 12px;
}
&__leftDelimitator {
border-left: var(--border-medium);
border-left-width: 3px;
margin-right: 16px;
}
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import cn from 'classnames/bind';
import styles from './IntegrationBlockItem.module.scss';
const cx = cn.bind(styles);
interface IntegrationBlockItemProps {
children: React.ReactNode;
}
const IntegrationBlockItem: React.FC<IntegrationBlockItemProps> = (props) => {
return (
<div className={cx('blockItem')}>
<div className={cx('blockItem__leftDelimitator')} />
<div className={cx('blockItem__content')}>{props.children}</div>
</div>
);
};
export default IntegrationBlockItem;

View file

@ -0,0 +1,52 @@
import React from 'react';
import { Button, HorizontalGroup, Icon, InlineLabel } from '@grafana/ui';
import Text from 'components/Text/Text';
interface IntegrationTemplateBlockProps {
label: string;
labelTooltip?: string;
renderInput: () => React.ReactNode;
showClose?: boolean;
showHelp?: boolean;
onEdit: (templateName) => void;
onRemove?: () => void;
onHelp?: () => void;
}
const IntegrationTemplateBlock: React.FC<IntegrationTemplateBlockProps> = ({
label,
labelTooltip,
renderInput,
showClose,
showHelp,
onEdit,
onHelp,
onRemove,
}) => {
let inlineLabelProps = { labelTooltip };
if (!labelTooltip) {
delete inlineLabelProps.labelTooltip;
}
return (
<HorizontalGroup align={'flex-start'}>
<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} />}
{showHelp && (
<Button variant="secondary" size="md" onClick={onHelp}>
<Text type="link">Help</Text>
<Icon name="angle-down" size="sm" />
</Button>
)}
</HorizontalGroup>
);
};
export default IntegrationTemplateBlock;

View file

@ -0,0 +1,448 @@
import React from 'react';
import { ButtonCascader, CascaderOption, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor';
import Text from 'components/Text/Text';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL, MONACO_OPTIONS } from './Integration2.config';
import IntegrationHelper from './Integration2.helper';
import styles from './Integration2.module.scss';
import IntegrationBlockItem from './IntegrationBlockItem';
import IntegrationTemplateBlock from './IntegrationTemplateBlock';
const cx = cn.bind(styles);
interface IntegrationTemplateListProps {
templates: AlertTemplatesDTO[];
getTemplatesList(): CascaderOption[];
openEditTemplateModal: (templateName: string | string[]) => void;
}
const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
templates,
openEditTemplateModal,
getTemplatesList,
}) => {
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'];
return (
<div className={cx('integration__templates')}>
<IntegrationBlockItem>
<Text type="secondary">
Templates are used to interpret alert from monitoring. Reduce noise, customize visualization
</Text>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<IntegrationTemplateBlock
label={'Grouping'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['grouping_id_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
showHelp
onEdit={() => openEditTemplateModal('grouping_id_template')}
/>
<IntegrationTemplateBlock
label={'Auto resolve'}
renderInput={() => (
<div className={cx('input', 'input--short')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['resolve_condition_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('resolve_condition_template')}
/>
</VerticalGroup>
</IntegrationBlockItem>
<IntegrationBlockItem>
<VerticalGroup>
<Text type={'primary'}>Web</Text>
<IntegrationTemplateBlock
label={'Title'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['web_title_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('web_title_template')}
/>
<IntegrationTemplateBlock
label={'Message'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['web_message_template'] || '', true)}
disabled={true}
height={MONACO_INPUT_HEIGHT_TALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('web_message_template')}
/>
<IntegrationTemplateBlock
label={'Image'}
renderInput={() => (
<div className={cx('input', 'input--long')}>
<MonacoJinja2Editor
value={IntegrationHelper.getFilteredTemplate(templates['web_image_url_template'] || '', false)}
disabled={true}
height={MONACO_INPUT_HEIGHT_SMALL}
data={templates}
showLineNumbers={false}
monacoOptions={MONACO_OPTIONS}
/>
</div>
)}
onEdit={() => openEditTemplateModal('web_image_url_template')}
/>
</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
/>
)}
{!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')}
/>
)}
</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>
)}
<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>
<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>
</VerticalGroup>
</IntegrationBlockItem>
</div>
);
};
export default IntegrationTemplateList;

View file

@ -0,0 +1,8 @@
.newIntegrationButton {
width: 180px;
}
.title {
margin-bottom: 24px;
right: 0;
}

View file

@ -0,0 +1,407 @@
import React from 'react';
import { HorizontalGroup, Badge, Tooltip, Button, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import Emoji from 'react-emoji-render';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import GTable from 'components/GTable/GTable';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import { Filters } from 'components/IntegrationsFilters/IntegrationsFilters';
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import IntegrationForm from 'containers/IntegrationForm/IntegrationForm';
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 { MaintenanceType } from 'models/maintenance/maintenance.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions, isUserActionAllowed } from 'utils/authorization';
import styles from './Integrations2.module.scss';
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
// const ITEMS_PER_PAGE = 25;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
page: number;
}
interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@observer
class Integrations extends React.Component<IntegrationsProps, IntegrationsState> {
state: IntegrationsState = {
integrationsFilters: { searchTerm: '' },
errorData: initErrorDataState(),
page: 1,
};
async componentDidMount() {
const {
query: { p },
} = this.props;
this.setState({ page: p ? Number(p) : 1 }, this.update);
this.parseQueryParams();
}
componentDidUpdate(prevProps: IntegrationsProps) {
if (prevProps.match.params.id !== this.props.match.params.id) {
this.parseQueryParams();
}
}
parseQueryParams = async () => {
this.setState((_prevState) => ({
errorData: initErrorDataState(),
alertReceiveChannelId: undefined,
})); // reset state on query parse
const {
store,
match: {
params: { id },
},
} = this.props;
if (!id) {
return;
}
let alertReceiveChannel: AlertReceiveChannel | void = undefined;
const isNewAlertReceiveChannel = id === 'new';
if (!isNewAlertReceiveChannel) {
alertReceiveChannel = await store.alertReceiveChannelStore
.loadItem(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
}
if (alertReceiveChannel || isNewAlertReceiveChannel) {
this.setState({ alertReceiveChannelId: id });
}
};
update = () => {
const { store } = this.props;
const { page, integrationsFilters } = this.state;
LocationHelper.update({ p: page }, 'partial');
return store.alertReceiveChannelStore.updateItems(integrationsFilters);
};
render() {
const { store, query } = this.props;
const { alertReceiveChannelId } = this.state;
const { grafanaTeamStore, alertReceiveChannelStore, heartbeatStore, maintenanceStore } = store;
const results = alertReceiveChannelStore.getSearchResult();
const columns = [
{
width: '25%',
title: 'Name',
key: 'name',
render: this.renderName,
},
{
width: '15%',
title: 'Status',
key: 'status',
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '25%',
title: 'Datasource',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
},
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item, maintenanceStore, alertReceiveChannelStore),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item, alertReceiveChannelStore, heartbeatStore),
},
{
width: '20%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
{
width: '50px',
key: 'buttons',
render: (item: AlertReceiveChannel) => this.renderButtons(item),
className: cx('buttons'),
},
];
return (
<>
<div className={cx('root')}>
<div className={cx('title')}>
<HorizontalGroup justify="flex-end">
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Button
onClick={() => {
this.setState({ alertReceiveChannelId: 'new' });
}}
icon="plus"
className={cx('newIntegrationButton')}
>
New integration
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
<div>
<RemoteFilters
query={query}
page="integrations"
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
/>
<GTable
emptyText={this.renderNotFound()}
rowKey="id"
data={results}
columns={columns}
// pagination={{
// page,
// total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
// onChange: this.handleChangePage,
// }}
/>
</div>
</div>
{alertReceiveChannelId && (
<IntegrationForm
onHide={() => {
this.setState({ alertReceiveChannelId: undefined });
}}
onUpdate={this.update}
id={alertReceiveChannelId}
/>
)}
</>
);
}
handleChangePage = (page: number) => {
this.setState({ page }, this.update);
};
renderNotFound() {
return (
<div className={cx('loader')}>
<Text type="secondary">Not found</Text>
</div>
);
}
renderName(item: AlertReceiveChannel) {
return (
<PluginLink query={{ page: 'integrations_2', id: item.id }}>
<Text type="link" size="medium">
<Emoji className={cx('title')} text={item.verbal_name} />
</Text>
</PluginLink>
);
}
renderDatasource(item: AlertReceiveChannel, alertReceiveChannelStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
return (
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary" size="small">
{integration?.display_name}
</Text>
</HorizontalGroup>
);
}
renderIntegrationStatus(item: AlertReceiveChannel, alertReceiveChannelStore) {
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[item.id];
let routesCounter = undefined;
return (
<HorizontalGroup>
{alertReceiveChannelCounter && (
<PluginLink query={{ page: 'incidents', integration: item.id }} className={cx('alertsInfoText')}>
<Badge
text={alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count}
color={'blue'}
tooltip={
alertReceiveChannelCounter?.alerts_count +
' alert' +
(alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's') +
' in ' +
alertReceiveChannelCounter?.alert_groups_count +
' alert group' +
(alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's')
}
/>
</PluginLink>
)}
{routesCounter && <Badge text={routesCounter} color={'green'} tooltip={`${routesCounter} routes`} />}
</HorizontalGroup>
);
}
renderHeartbeat(item: AlertReceiveChannel, alertReceiveChannelStore, heartbeatStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
const heartbeat = heartbeatStore.items[heartbeatId];
const heartbeatStatus = Boolean(heartbeat?.status);
return (
<div className={cx('heartbeat')}>
{alertReceiveChannel.is_available_for_integration_heartbeat && (
<Tooltip
placement="top"
content={
heartbeat
? `Last heartbeat: ${heartbeat.last_heartbeat_time_verbal || 'never'}`
: 'Click to setup heartbeat'
}
>
<div className={cx('heartbeat-icon')} onClick={() => {}}>
{heartbeatStatus ? <HeartGreenIcon /> : <HeartRedIcon />}
</div>
</Tooltip>
)}
</div>
);
}
convertTimestampToTimeDifference(timestamp: string) {
const date = new Date(Number(timestamp) * 1000);
const timezoneOffset = new Date().getTimezoneOffset() * 60;
const localTimestamp = date.getTime() + timezoneOffset;
const currentTime = Date.now();
const difference = localTimestamp - currentTime;
let timeLeft;
if (difference < 3600000) {
timeLeft = Math.floor(difference / 60000) + 'm left';
} else {
timeLeft = Math.floor(difference / 3600000) + 'h left';
}
return timeLeft;
}
renderMaintenance(item: AlertReceiveChannel, maintenanceStore, alertReceiveChannelStore) {
const maintenanceMode = item.maintenance_mode;
const maintenanceTill = item.maintenance_till;
if (maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance) {
return (
<Tooltip placement="top" content="Stop maintenance mode">
<Badge
text={
<Button
className={cx('maintenance-button')}
disabled={!isUserActionAllowed(UserActions.MaintenanceWrite)}
fill="text"
icon="pause"
onClick={() => this.handleStopMaintenance(item, maintenanceStore, alertReceiveChannelStore)}
>
{this.convertTimestampToTimeDifference(maintenanceTill)}
</Button>
}
color={maintenanceMode === MaintenanceMode.Debug ? 'orange' : 'blue'}
tooltip={
maintenanceMode === MaintenanceMode.Debug
? `Debug Maintenance: ${this.convertTimestampToTimeDifference(maintenanceTill)} left`
: `Maintenance: ${this.convertTimestampToTimeDifference(maintenanceTill)} left`
}
/>
</Tooltip>
);
}
return null;
}
handleStopMaintenance = (item: AlertReceiveChannel, maintenanceStore, alertReceiveChannelStore) => {
maintenanceStore.stopMaintenanceMode(MaintenanceType.alert_receive_channel, item.id).then(() => {
alertReceiveChannelStore.updateItem(item.id);
});
};
renderTeam(item: AlertReceiveChannel, teams: any) {
return <TeamName team={teams[item.team]} />;
}
renderButtons = (item: AlertReceiveChannel) => {
return (
<HorizontalGroup justify="flex-end">
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<IconButton tooltip="Settings" name="cog" onClick={() => this.onIntegrationEditClick(item.id)} />
</WithPermissionControlTooltip>
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<WithConfirm>
<IconButton
tooltip="Delete"
name="trash-alt"
onClick={() => this.handleDeleteAlertReceiveChannel(item.id)}
/>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
);
};
onIntegrationEditClick = (id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelId: id });
};
handleDeleteAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
alertReceiveChannelStore.deleteAlertReceiveChannel(alertReceiveChannelId).then(this.applyFilters);
};
handleIntegrationsFiltersChange = (integrationsFilters: Filters) => {
this.setState({ integrationsFilters }, () => this.debouncedUpdateIntegrations());
};
applyFilters = () => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters } = this.state;
return alertReceiveChannelStore.updateItems(integrationsFilters);
};
debouncedUpdateIntegrations = debounce(this.applyFilters, FILTERS_DEBOUNCE_MS);
}
export default withRouter(withMobXProviderContext(Integrations));

View file

@ -14,6 +14,8 @@ import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
import LiveSettingsPage from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
import UsersPage from 'pages/users/Users';
import IntegrationsPage2 from './integrations_2/Integrations2';
export interface NavRoute {
id: string;
component: (props?: any) => JSX.Element;
@ -36,6 +38,10 @@ export const routes: { [id: string]: NavRoute } = [
component: IntegrationsPage,
id: 'integrations',
},
{
component: IntegrationsPage2,
id: 'integrations_2',
},
{
component: EscalationsChainsPage,
id: 'escalations',

View file

@ -8,11 +8,11 @@ import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Avatar from 'components/Avatar/Avatar';
import CounterBadge from 'components/CounterBadge/CounterBadge';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
import PluginLink from 'components/PluginLink/PluginLink';
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
import Table from 'components/Table/Table';
import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
@ -306,8 +306,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
return (
<HorizontalGroup>
{item.number_of_escalation_chains > 0 && (
<StatusCounterBadgeWithTooltip
type="link"
<CounterBadge
borderType="link"
icon="link"
count={item.number_of_escalation_chains}
tooltipTitle="Used in escalations"
tooltipContent={
@ -325,7 +326,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
'Not used yet'
)
) : (
<LoadingPlaceholder>Loading related escalation chains....</LoadingPlaceholder>
<LoadingPlaceholder text="Loading related escalation chains..." />
)}
</VerticalGroup>
}
@ -334,8 +335,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
)}
{item.warnings?.length > 0 && (
<StatusCounterBadgeWithTooltip
type="warning"
<CounterBadge
borderType="warning"
icon="exclamation-triangle"
count={item.warnings.length}
tooltipTitle="Warnings"
tooltipContent={

View file

@ -8,6 +8,7 @@ import LegacyNavHeading from 'navbar/LegacyNavHeading';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Avatar from 'components/Avatar/Avatar';
import CounterBadge from 'components/CounterBadge/CounterBadge';
import GTable from 'components/GTable/GTable';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
@ -15,7 +16,6 @@ import {
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
import Text from 'components/Text/Text';
import UsersFilters from 'components/UsersFilters/UsersFilters';
import UserSettings from 'containers/UserSettings/UserSettings';
@ -361,8 +361,9 @@ class Users extends React.Component<UsersProps, UsersState> {
return (
<HorizontalGroup>
<StatusCounterBadgeWithTooltip
type="warning"
<CounterBadge
borderType="warning"
icon="exclamation-triangle"
count={texts.length}
tooltipTitle="Warnings"
tooltipContent={

View file

@ -24,7 +24,9 @@ import NoMatch from 'pages/NoMatch';
import EscalationChains from 'pages/escalation-chains/EscalationChains';
import Incident from 'pages/incident/Incident';
import Incidents from 'pages/incidents/Incidents';
import Integration2 from 'pages/integration_2/Integration2';
import Integrations from 'pages/integrations/Integrations';
import Integrations2 from 'pages/integrations_2/Integrations2';
import Maintenance from 'pages/maintenance/Maintenance';
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
@ -151,6 +153,12 @@ export const Root = observer((props: AppRootProps) => {
<Route path={getRoutesForPage('integrations')} exact>
<Integrations query={query} />
</Route>
<Route path={getRoutesForPage('integrations_2')} exact>
<Integrations2 query={query} />
</Route>
<Route path={getRoutesForPage('integration_2')} exact>
<Integration2 query={query} />
</Route>
<Route path={getRoutesForPage('escalations')} exact>
<EscalationChains query={query} />
</Route>

View file

@ -1,175 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PluginSetup app initialized with topnavbar = false 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
Initializing plugin...
</div>
</div>
</div>
`;
exports[`PluginSetup app initialized with topnavbar = true 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
Initializing plugin...
</div>
</div>
</div>
`;
exports[`PluginSetup app is loading 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
Initializing plugin...
</div>
</div>
</div>
`;
exports[`PluginSetup app successfully initialized 1`] = `
<div>
<div>
hello
</div>
</div>
`;
exports[`PluginSetup there is an error message - retry setup 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
ohhhh noo
</div>
<div
class="configure-plugin"
>
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-zy62io-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<a
class="css-zy62io-button"
href="/plugins/grafana-oncall-app?page=configuration"
tabindex="0"
>
<span
class="css-1mhnkuh"
>
Configure Plugin
</span>
</a>
</div>
</div>
</div>
</div>
</div>
`;
exports[`PluginSetup there is an error message 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
ohhhh noo
</div>
<div
class="configure-plugin"
>
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-zy62io-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<a
class="css-zy62io-button"
href="/plugins/grafana-oncall-app?page=configuration"
tabindex="0"
>
<span
class="css-1mhnkuh"
>
Configure Plugin
</span>
</a>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -61,3 +61,10 @@
.u-cursor-default {
cursor: default;
}
.thin-line-break {
width: 100%;
border-top: 1px solid var(--always-gray);
margin-top: 8px;
opacity: 15%;
}

View file

@ -21,11 +21,16 @@
--tag-warning: #c69b06;
--tag-primary: #299c46;
--tag-secondary: #464c54;
--tag-background-primary: rgba(56, 113, 220, 0.2);
--tag-border-primary: rgba(56, 113, 220, 0.2);
--tag-text-primary: rgba(110, 159, 255, 1);
--tag-border-danger: rgb(151, 11, 27);
--tag-text-danger: rgb(247, 144, 156);
--tag-border-warning: rgb(150, 75, 0);
--tag-background-warning: rgba(245, 183, 61, 0.18);
--tag-text-warning: rgb(255, 190, 124);
--tag-border-success: rgb(49, 100, 43);
--tag-background-success: rgba(27, 133, 94, 0.15);
--tag-text-success: rgb(165, 214, 159);
}

View file

@ -33,3 +33,5 @@ export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/chat-
// Make sure if you chage max-width here you also change it in responsive.css
export const TABLE_COLUMN_MAX_WIDTH = 1500;
export const DATASOURCE_ALERTING = 'alertmanager';

View file

@ -1155,6 +1155,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.18.0":
version "7.21.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.18.10", "@babel/template@^7.18.6", "@babel/template@^7.3.3":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
@ -1482,32 +1489,6 @@
dependencies:
tslib "2.4.0"
"@grafana/data@9.2.4", "@grafana/data@^9.2.4":
version "9.2.4"
resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.4.tgz#38067f207006c07754c3ed5a8835dc1909df7e2d"
integrity sha512-ukrvtQ0CzijpRZhBriv3LX935BKXRX4jf9l+jgK2uZJSYFAMbgz/Fvfagfr7sYmIPe8Ms4r3hslu2hbynWHzTw==
dependencies:
"@braintree/sanitize-url" "6.0.0"
"@grafana/schema" "9.2.4"
"@types/d3-interpolate" "^1.4.0"
d3-interpolate "1.4.0"
date-fns "2.29.1"
eventemitter3 "4.0.7"
fast_array_intersect "1.1.0"
history "4.10.1"
lodash "4.17.21"
marked "4.1.0"
moment "2.29.4"
moment-timezone "0.5.35"
ol "6.15.1"
papaparse "5.3.2"
regenerator-runtime "0.13.9"
rxjs "7.5.6"
tinycolor2 "1.4.2"
tslib "2.4.0"
uplot "1.6.22"
xss "1.0.13"
"@grafana/data@9.2.6":
version "9.2.6"
resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.6.tgz#a8b108fe882a16349e903013e62cb6c741f82135"
@ -1560,14 +1541,58 @@
uplot "1.6.22"
xss "1.0.14"
"@grafana/e2e-selectors@9.2.4":
version "9.2.4"
resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.2.4.tgz#748539cc0313ee1c23055a100313235ef2fca64b"
integrity sha512-k8Pqjb5yZa/rT0djUNceiDQCN6SIpYciwJbfn/8fl5zAEMLpInx9n8EfnefkinaAfxKcMB4IhDH/R+l4D0hAlQ==
"@grafana/data@9.4.7":
version "9.4.7"
resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.4.7.tgz#8b4c15a5b52ec13908c006baf87416354ee8251a"
integrity sha512-GnP91XSuTlRaT4crRh7OgC58rKsF/ANAZTFeHOYqVD7r47upTgnnnM46khSLhvA3MoKfNZflXOneaIjU4c5Hyw==
dependencies:
"@grafana/tsconfig" "^1.2.0-rc1"
"@braintree/sanitize-url" "6.0.1"
"@grafana/schema" "9.4.7"
"@types/d3-interpolate" "^3.0.0"
d3-interpolate "3.0.1"
date-fns "2.29.3"
eventemitter3 "4.0.7"
fast_array_intersect "1.1.0"
history "4.10.1"
lodash "4.17.21"
marked "4.2.0"
moment "2.29.4"
moment-timezone "0.5.38"
ol "7.1.0"
papaparse "5.3.2"
react-use "17.4.0"
regenerator-runtime "0.13.10"
rxjs "7.5.7"
tinycolor2 "1.4.2"
tslib "2.4.1"
uplot "1.6.24"
xss "1.0.14"
"@grafana/data@^9.2.4":
version "9.2.4"
resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.4.tgz#38067f207006c07754c3ed5a8835dc1909df7e2d"
integrity sha512-ukrvtQ0CzijpRZhBriv3LX935BKXRX4jf9l+jgK2uZJSYFAMbgz/Fvfagfr7sYmIPe8Ms4r3hslu2hbynWHzTw==
dependencies:
"@braintree/sanitize-url" "6.0.0"
"@grafana/schema" "9.2.4"
"@types/d3-interpolate" "^1.4.0"
d3-interpolate "1.4.0"
date-fns "2.29.1"
eventemitter3 "4.0.7"
fast_array_intersect "1.1.0"
history "4.10.1"
lodash "4.17.21"
marked "4.1.0"
moment "2.29.4"
moment-timezone "0.5.35"
ol "6.15.1"
papaparse "5.3.2"
regenerator-runtime "0.13.9"
rxjs "7.5.6"
tinycolor2 "1.4.2"
tslib "2.4.0"
typescript "4.8.2"
uplot "1.6.22"
xss "1.0.13"
"@grafana/e2e-selectors@9.2.6":
version "9.2.6"
@ -1587,6 +1612,15 @@
tslib "2.4.1"
typescript "4.8.4"
"@grafana/e2e-selectors@9.4.7":
version "9.4.7"
resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.4.7.tgz#7632bf927dc885ddeea0a865084badf93b2d777a"
integrity sha512-HvLgA9gccMC1uPx5Q+858yPjkfD5O0Kekm0p/ufQn+BA8dFbPpqVVd5cnu+/J3duKKHOsGBvZIShIOKNzkYw8g==
dependencies:
"@grafana/tsconfig" "^1.2.0-rc1"
tslib "2.4.1"
typescript "4.8.4"
"@grafana/eslint-config@5.0.0", "@grafana/eslint-config@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-5.0.0.tgz#e08a89d378772340bc6cd1872ec4d15666269aba"
@ -1696,6 +1730,13 @@
dependencies:
tslib "2.4.1"
"@grafana/schema@9.4.7":
version "9.4.7"
resolved "https://registry.yarnpkg.com/@grafana/schema/-/schema-9.4.7.tgz#bb918ec7f096e0b81d7ead921ac1addeb265dd0e"
integrity sha512-uTrg/XmMhfxXTSRskNRdUzDCK9XdwHHnNJkfUltzSF5v16bc9iE1u/NrkuEBxoLh6hji9Gd6pw7mS0K9o9/0ww==
dependencies:
tslib "2.4.1"
"@grafana/toolkit@^9.2.4":
version "9.2.6"
resolved "https://registry.yarnpkg.com/@grafana/toolkit/-/toolkit-9.2.6.tgz#55d424321a65a027f3365c6e0df649bcc1d2c9d6"
@ -1927,18 +1968,19 @@
uplot "1.6.22"
uuid "9.0.0"
"@grafana/ui@^9.2.4":
version "9.2.4"
resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.4.tgz#885b0f10bd700aa0dc094f2fcb554477fc47e410"
integrity sha512-V9sNQwcAkMAmWjM/DLMw9X+J0nqBmrwNV1uJ1kyS+3cRRwCNyJsZUz3NuOnzCbvCEl3bopLyY/WBSHONbLEoig==
"@grafana/ui@^9.4.7":
version "9.4.7"
resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.4.7.tgz#19ed1b36db85013070da118f4d87f13abb38567c"
integrity sha512-MnEXrGRh3t4LkShP/Q0bfzFooiE4xbDagQ/17/B1VIwMWECsYeSQsEYuA2p/9yjTpOiL2YfB72uyAThpGYpQew==
dependencies:
"@emotion/css" "11.9.0"
"@emotion/react" "11.9.3"
"@grafana/data" "9.2.4"
"@grafana/e2e-selectors" "9.2.4"
"@grafana/schema" "9.2.4"
"@monaco-editor/react" "4.4.5"
"@popperjs/core" "2.11.5"
"@emotion/css" "11.10.5"
"@emotion/react" "11.10.5"
"@grafana/data" "9.4.7"
"@grafana/e2e-selectors" "9.4.7"
"@grafana/schema" "9.4.7"
"@leeoniya/ufuzzy" "0.9.0"
"@monaco-editor/react" "4.4.6"
"@popperjs/core" "2.11.6"
"@react-aria/button" "3.6.1"
"@react-aria/dialog" "3.3.1"
"@react-aria/focus" "3.8.0"
@ -1949,49 +1991,52 @@
"@sentry/browser" "6.19.7"
ansicolor "1.1.100"
calculate-size "1.1.1"
classnames "2.3.1"
core-js "3.25.1"
d3 "5.15.0"
date-fns "2.29.1"
classnames "2.3.2"
core-js "3.27.1"
d3 "7.8.2"
date-fns "2.29.3"
hoist-non-react-statics "3.3.2"
immutable "4.1.0"
i18next "^22.0.0"
immutable "4.2.2"
is-hotkey "0.2.0"
jquery "3.6.0"
jquery "3.6.1"
lodash "4.17.21"
memoize-one "6.0.0"
moment "2.29.4"
monaco-editor "0.34.0"
ol "6.15.1"
ol "7.1.0"
prismjs "1.29.0"
rc-cascader "3.6.1"
rc-drawer "4.4.3"
rc-slider "9.7.5"
rc-cascader "3.8.0"
rc-drawer "6.1.2"
rc-slider "10.1.0"
rc-time-picker "^3.7.3"
react-beautiful-dnd "13.1.0"
react-calendar "3.7.0"
react-colorful "5.5.1"
rc-tooltip "5.3.1"
react-beautiful-dnd "13.1.1"
react-calendar "3.9.0"
react-colorful "5.6.1"
react-custom-scrollbars-2 "4.5.0"
react-dropzone "14.2.2"
react-highlight-words "0.18.0"
react-dropzone "14.2.3"
react-highlight-words "0.20.0"
react-hook-form "7.5.3"
react-inlinesvg "3.0.0"
react-i18next "^12.0.0"
react-inlinesvg "3.0.1"
react-popper "2.3.0"
react-popper-tooltip "^4.3.1"
react-popper-tooltip "4.4.2"
react-router-dom "^5.2.0"
react-select "5.4.0"
react-select "5.6.0"
react-select-event "^5.1.0"
react-table "7.8.0"
react-transition-group "4.4.2"
react-transition-group "4.4.5"
react-use "17.4.0"
react-window "1.8.7"
rxjs "7.5.6"
react-window "1.8.8"
rxjs "7.5.7"
slate "0.47.9"
slate-plain-serializer "0.7.11"
slate-plain-serializer "0.7.13"
slate-react "0.22.10"
tinycolor2 "1.4.2"
tslib "2.4.0"
uplot "1.6.22"
uuid "8.3.2"
tslib "2.4.1"
uplot "1.6.24"
uuid "9.0.0"
"@humanwhocodes/config-array@^0.11.6":
version "0.11.7"
@ -2326,6 +2371,11 @@
resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-0.8.0.tgz#2ccfc29453e168ce5866bf6dee89771db404a7f7"
integrity sha512-EOc0fEsIqe6CDZxC14efhybnPcXyJi7VaZby40mWASZD0CI78ONoF+4+LGlcT58jsAIwEims5ARbRqo+BVHEAQ==
"@leeoniya/ufuzzy@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-0.9.0.tgz#efb8f19f64ef6ff754fc49935c9ad53ab99712c1"
integrity sha512-p2zWsX0GwO1x723Yhb3KLAoSwp1geQvzRPHgIoOR/0qn8Ptpsb3b01+W47iAYR/NWo0pX36XQoTU0alVRykMAg==
"@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"
@ -2628,6 +2678,15 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
"@rc-component/portal@^1.0.0-6":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@rc-component/portal/-/portal-1.1.1.tgz#1a30ffe51c240b54360cba8e8bfc5d1f559325c4"
integrity sha512-m8w3dFXX0H6UkJ4wtfrSwhe2/6M08uz24HHrF8pWfAXPwA9hwCuTE5per/C86KwNLouRpwFGcr7LfpHaa1F38g==
dependencies:
"@babel/runtime" "^7.18.0"
classnames "^2.3.2"
rc-util "^5.24.4"
"@react-aria/button@3.6.1":
version "3.6.1"
resolved "https://registry.yarnpkg.com/@react-aria/button/-/button-3.6.1.tgz#111e296df8e171e4eb227c306f087337490bc896"
@ -3135,6 +3194,11 @@
dependencies:
"@types/node" "*"
"@types/d3-color@*":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4"
integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==
"@types/d3-color@^1":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-1.4.2.tgz#944f281d04a0f06e134ea96adbb68303515b2784"
@ -3147,6 +3211,13 @@
dependencies:
"@types/d3-color" "^1"
"@types/d3-interpolate@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==
dependencies:
"@types/d3-color" "*"
"@types/dompurify@^2.3.4":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
@ -4962,7 +5033,7 @@ classnames@2.3.1:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1:
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
@ -5106,16 +5177,16 @@ commander@2, commander@^2.20.0, commander@^2.20.3:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@7, commander@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@^6.2.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
commander@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@ -5252,6 +5323,11 @@ core-js@3.26.0:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.26.0.tgz#a516db0ed0811be10eac5d94f3b8463d03faccfe"
integrity sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==
core-js@3.27.1:
version "3.27.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.27.1.tgz#23cc909b315a6bb4e418bf40a52758af2103ba46"
integrity sha512-GutwJLBChfGCpwwhbYoqfv03LAfmiz7e7D/BNxzeMxwQf10GRSzqiOjx7AmtEk+heiD/JWmBuyBPgFtx0Sg1ww==
core-js@^2.4.0:
version "2.6.12"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
@ -5526,11 +5602,23 @@ d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0:
version "3.2.3"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.3.tgz#39f1f4954e4a09ff69ac597c2d61906b04e84740"
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
dependencies:
internmap "1 - 2"
d3-axis@1:
version "1.0.12"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
d3-axis@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322"
integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==
d3-brush@1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-1.1.6.tgz#b0a22c7372cabec128bdddf9bddc058592f89e9b"
@ -5542,6 +5630,17 @@ d3-brush@1:
d3-selection "1"
d3-transition "1"
d3-brush@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c"
integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "3"
d3-transition "3"
d3-chord@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-1.0.6.tgz#309157e3f2db2c752f0280fedd35f2067ccbb15f"
@ -5550,6 +5649,13 @@ d3-chord@1:
d3-array "1"
d3-path "1"
d3-chord@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966"
integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==
dependencies:
d3-path "1 - 3"
d3-collection@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
@ -5560,6 +5666,11 @@ d3-color@1:
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
"d3-color@1 - 3", d3-color@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
d3-contour@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3"
@ -5567,11 +5678,30 @@ d3-contour@1:
dependencies:
d3-array "^1.1.1"
d3-contour@4:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.2.tgz#bb92063bc8c5663acb2422f99c73cbb6c6ae3bcc"
integrity sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==
dependencies:
d3-array "^3.2.0"
d3-delaunay@6:
version "6.0.4"
resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz#98169038733a0a5babbeda55054f795bb9e4a58b"
integrity sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==
dependencies:
delaunator "5"
d3-dispatch@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
"d3-dispatch@1 - 3", d3-dispatch@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
d3-drag@1:
version "1.2.5"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
@ -5580,6 +5710,14 @@ d3-drag@1:
d3-dispatch "1"
d3-selection "1"
"d3-drag@2 - 3", d3-drag@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
dependencies:
d3-dispatch "1 - 3"
d3-selection "3"
d3-dsv@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c"
@ -5589,11 +5727,25 @@ d3-dsv@1:
iconv-lite "0.4"
rw "1"
"d3-dsv@1 - 3", d3-dsv@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73"
integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==
dependencies:
commander "7"
iconv-lite "0.6"
rw "1"
d3-ease@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2"
integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==
"d3-ease@1 - 3", d3-ease@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
d3-fetch@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-1.2.0.tgz#15ce2ecfc41b092b1db50abd2c552c2316cf7fc7"
@ -5601,6 +5753,13 @@ d3-fetch@1:
dependencies:
d3-dsv "1"
d3-fetch@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22"
integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==
dependencies:
d3-dsv "1 - 3"
d3-force@1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.2.1.tgz#fd29a5d1ff181c9e7f0669e4bd72bdb0e914ec0b"
@ -5611,11 +5770,25 @@ d3-force@1:
d3-quadtree "1"
d3-timer "1"
d3-force@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
dependencies:
d3-dispatch "1 - 3"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
d3-format@1:
version "1.4.5"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
"d3-format@1 - 3", d3-format@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
d3-geo@1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.1.tgz#7fc2ab7414b72e59fbcbd603e80d9adc029b035f"
@ -5623,11 +5796,23 @@ d3-geo@1:
dependencies:
d3-array "1"
d3-geo@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.0.tgz#74fd54e1f4cebd5185ac2039217a98d39b0a4c0e"
integrity sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==
dependencies:
d3-array "2.5.0 - 3"
d3-hierarchy@1:
version "1.1.9"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
d3-hierarchy@3:
version "3.1.2"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6"
integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==
d3-interpolate@1, d3-interpolate@1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
@ -5635,26 +5820,53 @@ d3-interpolate@1, d3-interpolate@1.4.0:
dependencies:
d3-color "1"
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3, d3-interpolate@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
d3-path@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
"d3-path@1 - 3", d3-path@3, d3-path@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
d3-polygon@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
d3-polygon@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398"
integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==
d3-quadtree@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135"
integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==
"d3-quadtree@1 - 3", d3-quadtree@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
d3-random@1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291"
integrity sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==
d3-random@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4"
integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==
d3-scale-chromatic@1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98"
@ -5663,6 +5875,14 @@ d3-scale-chromatic@1:
d3-color "1"
d3-interpolate "1"
d3-scale-chromatic@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a"
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
dependencies:
d3-color "1 - 3"
d3-interpolate "1 - 3"
d3-scale@2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
@ -5675,11 +5895,27 @@ d3-scale@2:
d3-time "1"
d3-time-format "2"
d3-scale@4:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
dependencies:
d3-array "2.10.0 - 3"
d3-format "1 - 3"
d3-interpolate "1.2.0 - 3"
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
d3-selection@1, d3-selection@^1.1.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c"
integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==
"d3-selection@2 - 3", d3-selection@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@1:
version "1.3.7"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
@ -5687,6 +5923,13 @@ d3-shape@1:
dependencies:
d3-path "1"
d3-shape@3:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
dependencies:
d3-path "^3.1.0"
d3-time-format@2:
version "2.3.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850"
@ -5694,16 +5937,35 @@ d3-time-format@2:
dependencies:
d3-time "1"
"d3-time-format@2 - 4", d3-time-format@4:
version "4.1.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
dependencies:
d3-time "1 - 3"
d3-time@1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
dependencies:
d3-array "2 - 3"
d3-timer@1:
version "1.0.10"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
"d3-timer@1 - 3", d3-timer@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
d3-transition@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
@ -5716,6 +5978,17 @@ d3-transition@1:
d3-selection "^1.1.0"
d3-timer "1"
"d3-transition@2 - 3", d3-transition@3:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
dependencies:
d3-color "1 - 3"
d3-dispatch "1 - 3"
d3-ease "1 - 3"
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-voronoi@1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"
@ -5732,6 +6005,17 @@ d3-zoom@1:
d3-selection "1"
d3-transition "1"
d3-zoom@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "2 - 3"
d3-transition "2 - 3"
d3@5.15.0:
version "5.15.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-5.15.0.tgz#ffd44958e6a3cb8a59a84429c45429b8bca5677a"
@ -5769,6 +6053,42 @@ d3@5.15.0:
d3-voronoi "1"
d3-zoom "1"
d3@7.8.2:
version "7.8.2"
resolved "https://registry.yarnpkg.com/d3/-/d3-7.8.2.tgz#2bdb3c178d095ae03b107a18837ae049838e372d"
integrity sha512-WXty7qOGSHb7HR7CfOzwN1Gw04MUOzN8qh9ZUsvwycIMb4DYMpY9xczZ6jUorGtO6bR9BPMPaueIKwiDxu9uiQ==
dependencies:
d3-array "3"
d3-axis "3"
d3-brush "3"
d3-chord "3"
d3-color "3"
d3-contour "4"
d3-delaunay "6"
d3-dispatch "3"
d3-drag "3"
d3-dsv "3"
d3-ease "3"
d3-fetch "3"
d3-force "3"
d3-format "3"
d3-geo "3"
d3-hierarchy "3"
d3-interpolate "3"
d3-path "3"
d3-polygon "3"
d3-quadtree "3"
d3-random "3"
d3-scale "4"
d3-scale-chromatic "3"
d3-selection "3"
d3-shape "3"
d3-time "3"
d3-time-format "4"
d3-timer "3"
d3-transition "3"
d3-zoom "3"
data-urls@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
@ -5929,6 +6249,13 @@ del@^5.1.0:
rimraf "^3.0.0"
slash "^3.0.0"
delaunator@5:
version "5.0.0"
resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b"
integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==
dependencies:
robust-predicates "^3.0.0"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@ -7662,7 +7989,7 @@ iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.6.3:
iconv-lite@0.6, iconv-lite@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
@ -7699,6 +8026,11 @@ immutable@4.1.0, immutable@^4.0.0:
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
immutable@4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.2.2.tgz#2da9ff4384a4330c36d4d1bc88e90f9e0b0ccd16"
integrity sha512-fTMKDwtbvO5tldky9QZ2fMX7slR0mYpY5nbnFWYp0fOzDhHqhgIw9KoYgxLWsoNTS9ZHGauHj18DTyEw6BK3Og==
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -7804,6 +8136,11 @@ internal-slot@^1.0.3:
has "^1.0.3"
side-channel "^1.0.4"
"internmap@1 - 2":
version "2.0.3"
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
interpret@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
@ -11200,6 +11537,18 @@ rc-cascader@3.7.0:
rc-tree "~5.7.0"
rc-util "^5.6.1"
rc-cascader@3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.8.0.tgz#5eaca8998b2e3f5692d13f16bfe2346eccc87c6a"
integrity sha512-zCz/NzsNRQ1TIfiR3rQNxjeRvgRHEkNdo0FjHQZ6Ay6n4tdCmMrM7+81ThNaf21JLQ1gS2AUG2t5uikGV78obA==
dependencies:
"@babel/runtime" "^7.12.5"
array-tree-filter "^2.1.0"
classnames "^2.3.1"
rc-select "~14.2.0"
rc-tree "~5.7.0"
rc-util "^5.6.1"
rc-drawer@4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-4.4.3.tgz#2094937a844e55dc9644236a2d9fba79c344e321"
@ -11209,6 +11558,17 @@ rc-drawer@4.4.3:
classnames "^2.2.6"
rc-util "^5.7.0"
rc-drawer@6.1.2:
version "6.1.2"
resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-6.1.2.tgz#032918a21bfa8a7d9e52ada1e7b8ed08c0ae6346"
integrity sha512-mYsTVT8Amy0LRrpVEv7gI1hOjtfMSO/qHAaCDzFx9QBLnms3cAQLJkaxRWM+Eq99oyLhU/JkgoqTg13bc4ogOQ==
dependencies:
"@babel/runtime" "^7.10.1"
"@rc-component/portal" "^1.0.0-6"
classnames "^2.2.6"
rc-motion "^2.6.1"
rc-util "^5.21.2"
rc-motion@^2.0.0, rc-motion@^2.0.1:
version "2.6.2"
resolved "https://registry.yarnpkg.com/rc-motion/-/rc-motion-2.6.2.tgz#3d31f97e41fb8e4f91a4a4189b6a98ac63342869"
@ -11218,6 +11578,15 @@ rc-motion@^2.0.0, rc-motion@^2.0.1:
classnames "^2.2.1"
rc-util "^5.21.0"
rc-motion@^2.6.1:
version "2.6.3"
resolved "https://registry.yarnpkg.com/rc-motion/-/rc-motion-2.6.3.tgz#e6d8ca06591c2c1bcd3391a8e7a822ebc4d94e9c"
integrity sha512-xFLkes3/7VL/J+ah9jJruEW/Akbx5F6jVa2wG5o/ApGKQKSOd5FR3rseHLL9+xtJg4PmCwo6/1tqhDO/T+jFHA==
dependencies:
"@babel/runtime" "^7.11.1"
classnames "^2.2.1"
rc-util "^5.21.0"
rc-overflow@^1.0.0:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc-overflow/-/rc-overflow-1.2.8.tgz#40f140fabc244118543e627cdd1ef750d9481a88"
@ -11251,6 +11620,19 @@ rc-select@~14.1.0:
rc-util "^5.16.1"
rc-virtual-list "^3.2.0"
rc-select@~14.2.0:
version "14.2.2"
resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-14.2.2.tgz#03558848b190d24fc9010a3bf1104c6dbea9b122"
integrity sha512-w+LuiYGFWgaV23PuxtdeWtXSsoxt+eCfzxu/CvRuqSRm8tn/pqvAb1xUIDAjoMMWK1FqiOW4jI/iMt7ZRG/BBg==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "2.x"
rc-motion "^2.0.1"
rc-overflow "^1.0.0"
rc-trigger "^5.0.4"
rc-util "^5.16.1"
rc-virtual-list "^3.4.13"
rc-slider@10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-10.0.1.tgz#7058c68ff1e1aa4e7c3536e5e10128bdbccb87f9"
@ -11261,6 +11643,16 @@ rc-slider@10.0.1:
rc-util "^5.18.1"
shallowequal "^1.1.0"
rc-slider@10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-10.1.0.tgz#11e401d8412ae20f9c2ee478bdbaddd042158753"
integrity sha512-nhC8V0+lNj4gGKZix2QAfcj/EP3NvCtFhNJPFMvXUdn7pe8bSa2vXNSxQVN5b9veVSic4Xeqgd/7KamX3gqznA==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "^2.2.5"
rc-util "^5.18.1"
shallowequal "^1.1.0"
rc-slider@9.7.5:
version "9.7.5"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-9.7.5.tgz#193141c68e99b1dc3b746daeb6bf852946f5b7f4"
@ -11304,6 +11696,15 @@ rc-tooltip@5.2.2, rc-tooltip@^5.0.1:
classnames "^2.3.1"
rc-trigger "^5.0.0"
rc-tooltip@5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-5.3.1.tgz#3dde4e1865f79cd23f202bba4e585c2a1173024b"
integrity sha512-e6H0dMD38EPaSPD2XC8dRfct27VvT2TkPdoBSuNl3RRZ5tspiY/c5xYEmGC0IrABvMBgque4Mr2SMZuliCvoiQ==
dependencies:
"@babel/runtime" "^7.11.2"
classnames "^2.3.1"
rc-trigger "^5.3.1"
rc-tree@~5.6.3:
version "5.6.9"
resolved "https://registry.yarnpkg.com/rc-tree/-/rc-tree-5.6.9.tgz#b73290a6dcad65e4ed5d8dc21cb198b30316404b"
@ -11350,6 +11751,17 @@ rc-trigger@^5.0.0, rc-trigger@^5.0.4:
rc-motion "^2.0.0"
rc-util "^5.19.2"
rc-trigger@^5.3.1:
version "5.3.4"
resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-5.3.4.tgz#6b4b26e32825677c837d1eb4d7085035eecf9a61"
integrity sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==
dependencies:
"@babel/runtime" "^7.18.3"
classnames "^2.2.6"
rc-align "^4.0.0"
rc-motion "^2.0.0"
rc-util "^5.19.2"
rc-util@^4.0.4, rc-util@^4.15.3, rc-util@^4.4.0:
version "4.21.1"
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.21.1.tgz#88602d0c3185020aa1053d9a1e70eac161becb05"
@ -11370,6 +11782,14 @@ rc-util@^5.15.0, rc-util@^5.16.1, rc-util@^5.18.1, rc-util@^5.19.2, rc-util@^5.2
react-is "^16.12.0"
shallowequal "^1.1.0"
rc-util@^5.21.2, rc-util@^5.24.4:
version "5.29.3"
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.29.3.tgz#dc02b7b2103468e9fdf14e0daa58584f47898e37"
integrity sha512-wX6ZwQTzY2v7phJBquN4mSEIFR0E0qumlENx0zjENtDvoVSq2s7cR95UidKRO1hOHfDsecsfM9D1gO4Kebs7fA==
dependencies:
"@babel/runtime" "^7.18.3"
react-is "^16.12.0"
rc-virtual-list@^3.2.0, rc-virtual-list@^3.4.8:
version "3.4.11"
resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.4.11.tgz#97f5e947380d546a2ca8ad229d8e41e9b33b20c6"
@ -11380,6 +11800,16 @@ rc-virtual-list@^3.2.0, rc-virtual-list@^3.4.8:
rc-resize-observer "^1.0.0"
rc-util "^5.15.0"
rc-virtual-list@^3.4.13:
version "3.4.13"
resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.4.13.tgz#20acc934b263abcf7b7c161f50ef82281b2f7e8d"
integrity sha512-cPOVDmcNM7rH6ANotanMDilW/55XnFPw0Jh/GQYtrzZSy3AmWvCnqVNyNC/pgg3lfVmX2994dlzAhuUrd4jG7w==
dependencies:
"@babel/runtime" "^7.20.0"
classnames "^2.2.6"
rc-resize-observer "^1.0.0"
rc-util "^5.15.0"
react-beautiful-dnd@13.1.0:
version "13.1.0"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz#ec97c81093593526454b0de69852ae433783844d"
@ -11553,6 +11983,15 @@ react-highlight-words@0.18.0:
memoize-one "^4.0.0"
prop-types "^15.5.8"
react-highlight-words@0.20.0:
version "0.20.0"
resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.20.0.tgz#c60bfff5d14678c8f0e8fbe4bdcf083e6c70d507"
integrity sha512-asCxy+jCehDVhusNmCBoxDf2mm1AJ//D+EzDx1m5K7EqsMBIHdZ5G4LdwbSEXqZq1Ros0G0UySWmAtntSph7XA==
dependencies:
highlight-words-core "^1.2.0"
memoize-one "^4.0.0"
prop-types "^15.5.8"
react-hook-form@7.5.3:
version "7.5.3"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.5.3.tgz#9a624fa14ec153b154891c5ebddae02ec5c2e40f"
@ -11619,7 +12058,7 @@ react-modal@^3.15.1:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
react-popper-tooltip@^4.3.1:
react-popper-tooltip@4.4.2, react-popper-tooltip@^4.3.1:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163"
integrity sha512-y48r0mpzysRTZAIh8m2kpZ8S1YPNqGtQPDrlXYSGvDS1c1GpG/NUXbsbIdfbhXfmSaRJuTcaT6N1q3CKuHRVbg==
@ -12206,6 +12645,11 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
robust-predicates@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
rtl-css-js@^1.14.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/rtl-css-js/-/rtl-css-js-1.16.0.tgz#e8d682982441aadb63cabcb2f7385f3fb78ff26e"
@ -13644,6 +14088,11 @@ uplot@1.6.22:
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.22.tgz#28a136c7c5fce92ce5e25f38f19314a029bec390"
integrity sha512-2jtSb/YHUgtmIUn0+QJjf7ggcJicb5PKe7ijBiRDTPsG/f8F/MFayZ+g6/0kATNkDyF/qQsHJDmCp6cxncg1EQ==
uplot@1.6.24:
version "1.6.24"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.24.tgz#dfa213fa7da92763261920ea972ed1a5f9f6af12"
integrity sha512-WpH2BsrFrqxkMu+4XBvc0eCDsRBhzoq9crttYeSI0bfxpzR5YoSVzZXOKFVWcVC7sp/aDXrdDPbDZGCtck2PVg==
upper-case-first@^1.1.0, upper-case-first@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115"