Brojd/connect integration to snow (#3968)

# What this PR does


https://github.com/grafana/oncall/assets/12073649/0dad62c2-d722-4f5b-aee6-549dc97902cd


## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Dominik Broj 2024-02-28 13:19:18 +01:00 committed by GitHub
parent f886fee93c
commit 7794246efb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 188 additions and 60 deletions

View file

@ -1,4 +1,5 @@
import semver from 'semver';
import { test, expect } from '../fixtures';
import { resolveFiringAlert } from '../utils/alertGroup';
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';

View file

@ -1,7 +1,7 @@
import { test, expect, Page, Locator } from '../fixtures';
import { verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated } from '../utils/alertGroup';
import { EscalationStep, createEscalationChain } from '../utils/escalationChain';
import { clickButton, generateRandomValue, selectDropdownValue } from '../utils/forms';
import { generateRandomValue, selectDropdownValue } from '../utils/forms';
import {
assignEscalationChainToIntegration,
createIntegration,

View file

@ -10,7 +10,7 @@ import styles from './GTable.module.css';
const cx = cn.bind(styles);
export interface Props<RecordType = unknown> extends TableProps<RecordType> {
export interface GTableProps<RecordType = unknown> extends TableProps<RecordType> {
pagination?: {
page: number;
total: number;
@ -31,7 +31,7 @@ export interface Props<RecordType = unknown> extends TableProps<RecordType> {
showHeader?: boolean;
}
export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props: Props<RT>): ReactElement => {
export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props: GTableProps<RT>): ReactElement => {
const {
columns: columnsProp,
data,

View file

@ -39,5 +39,6 @@ export const getStyles = () => ({
}),
disabledBadge: css({
wordBreak: 'keep-all',
marginLeft: '8px',
}),
});

View file

@ -39,7 +39,10 @@ export const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardP
const heartbeatStatus = Boolean(heartbeat?.status);
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
alertReceiveChannelStore,
alertReceiveChannel
);
return (
<div className={cx('root')}>

View file

@ -43,7 +43,7 @@ export class AlertReceiveChannelHelper {
: undefined;
}
static getIntegration(
static getIntegrationSelectOption(
store: AlertReceiveChannelStore,
alertReceiveChannel: Partial<ApiSchemas['AlertReceiveChannel'] | ApiSchemas['FastAlertReceiveChannel']>
): SelectOption {

View file

@ -5,6 +5,7 @@ import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { ActionKey } from 'models/loader/action-keys';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
@ -12,7 +13,7 @@ import { operations } from 'network/oncall-api/autogenerated-api.types';
import { onCallApi } from 'network/oncall-api/http-client';
import { move } from 'state/helpers';
import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore';
import { WithGlobalNotification } from 'utils/decorators';
import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators';
import { OmitReadonlyMembers } from 'utils/types';
import { AlertReceiveChannelCounters, ContactPoint } from './alert_receive_channel.types';
@ -124,20 +125,23 @@ export class AlertReceiveChannelStore {
return results;
}
@AutoLoadingState(ActionKey.FETCH_INTEGRATIONS)
async fetchPaginatedItems({
filters,
page = 1,
shouldFetchCounters = false,
invalidateFn = undefined,
perpage,
}: {
filters: operations['alert_receive_channels_list']['parameters']['query'];
page: number;
shouldFetchCounters: boolean;
invalidateFn: () => boolean;
page?: number;
shouldFetchCounters?: boolean;
invalidateFn?: () => boolean;
perpage?: number;
}) {
const {
data: { count, results, page_size },
} = await onCallApi().GET('/alert_receive_channels/', { params: { query: { ...filters, page } } });
} = await onCallApi().GET('/alert_receive_channels/', { params: { query: { ...filters, page, perpage } } });
if (invalidateFn?.()) {
return undefined;
@ -173,6 +177,10 @@ export class AlertReceiveChannelStore {
return results;
}
resetPaginatedResults() {
this.paginatedSearchResult = {};
}
populateHearbeats(alertReceiveChannels: Array<ApiSchemas['AlertReceiveChannelPolymorphic']>) {
const heartbeats = alertReceiveChannels.reduce(
(acc: any, alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) => {

View file

@ -8,4 +8,5 @@ export enum ActionKey {
FETCH_INCIDENTS_POLLING = 'FETCH_INCIDENTS_POLLING',
FETCH_INCIDENTS_AND_STATS = 'FETCH_INCIDENTS_AND_STATS',
UPDATE_FILTERS_AND_FETCH_INCIDENTS = 'UPDATE_FILTERS_AND_FETCH_INCIDENTS',
FETCH_INTEGRATIONS = 'FETCH_INTEGRATIONS',
}

View file

@ -273,7 +273,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
} = this.props;
const { alerts } = store.alertGroupStore;
const incident = alerts.get(id);
const integration = AlertReceiveChannelHelper.getIntegration(
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
store.alertReceiveChannelStore,
incident.alert_receive_channel
);

View file

@ -630,7 +630,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
const {
store: { alertReceiveChannelStore },
} = this.props;
const integration = AlertReceiveChannelHelper.getIntegration(
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
alertReceiveChannelStore,
record.alert_receive_channel
);

View file

@ -152,7 +152,10 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
);
}
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
alertReceiveChannelStore,
alertReceiveChannel
);
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const isLegacyIntegration = integration && (integration?.value as string).toLowerCase().startsWith('legacy_');
const contactPoints = alertReceiveChannelStore.connectedContactPoints?.[alertReceiveChannel.id];

View file

@ -1,15 +1,65 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { Button, HorizontalGroup, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import cn from 'classnames';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react-lite';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ActionKey } from 'models/loader/action-keys';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { useCommonStyles, useIsLoading } from 'utils/hooks';
import ConnectedIntegrationsTable from './ConnectedIntegrationsTable';
import { getStyles } from './OutgoingTab.styles';
export const ConnectIntegrationModal = ({ onDismiss }: { onDismiss: () => void }) => {
const DEBOUNCE_MS = 500;
export const ConnectIntegrationModal = observer(({ onDismiss }: { onDismiss: () => void }) => {
const { alertReceiveChannelStore } = useStore();
const isLoading = useIsLoading(ActionKey.FETCH_INTEGRATIONS);
const commonStyles = useCommonStyles();
const [selectedIntegrations, setSelectedIntegrations] = useState<Array<ApiSchemas['AlertReceiveChannel']>>([]);
const [page, setPage] = useState(1);
const styles = useStyles2(getStyles);
const { count, results, page_size } = AlertReceiveChannelHelper.getPaginatedSearchResult(alertReceiveChannelStore);
useEffect(() => {
fetchItems();
return alertReceiveChannelStore.resetPaginatedResults;
}, [page]);
const fetchItems = async (search?: string) => {
await alertReceiveChannelStore.fetchPaginatedItems({
filters: { search },
perpage: 10,
page,
});
};
const onChange = (integration: ApiSchemas['AlertReceiveChannel'], checked) => {
if (checked) {
setSelectedIntegrations((integrations) => [...integrations, integration]);
} else {
setSelectedIntegrations((integrations) => integrations.filter(({ id }) => id !== integration.id));
}
};
const onConnect = () => {};
const debouncedSearch = debounce(fetchItems, DEBOUNCE_MS);
const onSearchInputChange = (searchTerm: string) => {
debouncedSearch(searchTerm);
};
const onChangePage = (page: number) => {
setPage(page);
};
return (
<Modal
isOpen
@ -17,13 +67,37 @@ export const ConnectIntegrationModal = ({ onDismiss }: { onDismiss: () => void }
closeOnBackdropClick={false}
closeOnEscape
onDismiss={onDismiss}
contentClassName={styles.connectIntegrationModalContent}
>
<Input
className={styles.searchIntegrationsInput}
suffix={<Icon name="search" />}
placeholder="Search integrations..."
onChange={(e) => onSearchInputChange(e.currentTarget.value)}
/>
<ConnectedIntegrationsTable />
<ConnectedIntegrationsTable
selectable
onChange={onChange}
tableProps={{
data: results,
pagination: {
page,
total: results ? Math.ceil((count || 0) / page_size) : 0,
onChange: onChangePage,
},
emptyText: isLoading ? 'Loading...' : 'No integrations found',
}}
/>
<div className={cn(commonStyles.bottomDrawerButtons, styles.connectIntegrationModalButtons)}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onDismiss}>
Close
</Button>
<Button variant="primary" onClick={onConnect} disabled={!selectedIntegrations?.length}>
Connect
</Button>
</HorizontalGroup>
</div>
</Modal>
);
};
});

View file

@ -1,57 +1,78 @@
import React, { FC } from 'react';
import { HorizontalGroup, Tooltip, Icon, useStyles2, IconButton, Switch } from '@grafana/ui';
import { HorizontalGroup, Tooltip, Icon, useStyles2, IconButton, Switch, Checkbox } from '@grafana/ui';
import { observer } from 'mobx-react';
import { GTable } from 'components/GTable/GTable';
import { GTable, GTableProps } from 'components/GTable/GTable';
import { IntegrationLogoWithTitle } from 'components/IntegrationLogo/IntegrationLogoWithTitle';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { getStyles } from './OutgoingTab.styles';
interface ConnectedIntegrationsTableProps {
allowDelete?: boolean;
allowBacksync?: boolean;
selectable?: boolean;
onChange?: (integration: ApiSchemas['AlertReceiveChannel'], checked: boolean) => void;
tableProps: GTableProps;
}
const ConnectedIntegrationsTable: FC<ConnectedIntegrationsTableProps> = (props) => {
const FAKE_INTEGRATIONS = [{ a: 'a' }];
const ConnectedIntegrationsTable: FC<ConnectedIntegrationsTableProps> = observer(
({ selectable, allowDelete, onChange, tableProps, allowBacksync }) => {
const { alertReceiveChannelStore } = useStore();
return (
<GTable
emptyText={FAKE_INTEGRATIONS ? 'No integrations found' : 'Loading...'}
rowKey="id"
columns={getColumns(props)}
data={FAKE_INTEGRATIONS}
/>
);
};
const columns = [
...(selectable
? [
{
width: '5%',
render: (integration: ApiSchemas['AlertReceiveChannel']) => (
<Checkbox onChange={(event) => onChange(integration, event.currentTarget.checked)} />
),
},
]
: []),
{
width: '45%',
title: <Text type="secondary">Integration name</Text>,
dataIndex: 'verbal_name',
render: (name: string) => name,
},
{
width: '55%',
title: <Text type="secondary">Type</Text>,
render: (integration: ApiSchemas['AlertReceiveChannel']) => (
<IntegrationLogoWithTitle
integration={AlertReceiveChannelHelper.getIntegrationSelectOption(alertReceiveChannelStore, integration)}
/>
),
},
...(allowBacksync
? [
{
title: (
<HorizontalGroup>
<Text type="secondary">Backsync</Text>
<Tooltip content={<>Switch on to start sending data from other integrations</>}>
<Icon name={'info-circle'} />
</Tooltip>
</HorizontalGroup>
),
render: BacksyncSwitcher,
},
]
: []),
{
render: () => <ActionsColumn allowDelete={allowDelete} />,
},
];
const getColumns = ({ allowDelete }: ConnectedIntegrationsTableProps) => [
{
width: '45%',
title: <Text type="secondary">Integration name</Text>,
dataIndex: 'trigger_type_name',
render: () => <>Some integration name</>,
},
{
width: '55%',
title: <Text type="secondary">Type</Text>,
render: () => <IntegrationLogoWithTitle integration={{ value: 'elastalert', display_name: 'ElastAlerts' }} />,
},
{
title: (
<HorizontalGroup>
<Text type="secondary">Backsync</Text>
<Tooltip content={<>Switch on to start sending data from other integrations</>}>
<Icon name={'info-circle'} />
</Tooltip>
</HorizontalGroup>
),
render: BacksyncSwitcher,
},
{
render: () => <ActionsColumn allowDelete={allowDelete} />,
},
];
return <GTable rowKey="id" columns={columns} {...tableProps} />;
}
);
const BacksyncSwitcher = () => {
const styles = useStyles2(getStyles);

View file

@ -24,7 +24,7 @@ export const OtherIntegrations = observer(() => {
</Button>
</HorizontalGroup>
}
content={<ConnectedIntegrationsTable allowDelete />}
content={<ConnectedIntegrationsTable allowDelete allowBacksync tableProps={{ data: [] }} />}
/>
</>
);

View file

@ -58,6 +58,9 @@ export const getStyles = (theme: GrafanaTheme2) => ({
backsyncColumn: css({
display: 'flex',
justifyContent: 'flex-end',
'& label': {
position: 'relative',
},
}),
triggerTemplateWrapper: css({
position: 'relative',
@ -77,4 +80,10 @@ export const getStyles = (theme: GrafanaTheme2) => ({
tabsWrapper: css({
padding: '16px 16px 0 8px',
}),
connectIntegrationModalContent: css({
paddingBottom: 0,
}),
connectIntegrationModalButtons: css({
marginTop: '50px',
}),
});

View file

@ -122,6 +122,10 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
}
}
componentWillUnmount() {
this.props.store.alertReceiveChannelStore.resetPaginatedResults();
}
parseQueryParams = async () => {
const {
store,
@ -353,7 +357,10 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
renderDatasource(item: ApiSchemas['AlertReceiveChannel'], alertReceiveChannelStore: AlertReceiveChannelStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
alertReceiveChannelStore,
alertReceiveChannel
);
const isLegacyIntegration = (integration?.value as string)?.toLowerCase().startsWith('legacy_');
if (isLegacyIntegration) {