Direct paging integrations table (#3290)

# What this PR does

Closes https://github.com/grafana/oncall/issues/3119
Closes https://github.com/grafana/oncall-private/issues/2061

## Which issue(s) this PR fixes

## 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)

---------

Co-authored-by: Dominik <dominik.broj@grafana.com>
This commit is contained in:
Joey Orlando 2023-11-10 11:54:22 -05:00 committed by GitHub
parent 152b1b1c58
commit 37160806ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 311 additions and 80 deletions

View file

@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added user timezone field to the users public API response ([#3311](https://github.com/grafana/oncall/pull/3311))
- Added user timezone field to the users public API response ([#3311](https://github.com/grafana/oncall/pull/3311))
### Changed
- Split Integrations table into Connections and Direct Paging tabs ([#3290](https://github.com/grafana/oncall/pull/3290))
## v1.3.57 (2023-11-10)

View file

@ -33,6 +33,29 @@ def test_get_alert_receive_channel(alert_receive_channel_internal_api_setup, mak
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_get_alert_receive_channel_by_integration_ne(
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_alert_receive_channel
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA)
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING)
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
client = APIClient()
url = f"{reverse('api-internal:alert_receive_channel-list')}?integration_ne={AlertReceiveChannel.INTEGRATION_DIRECT_PAGING}"
response = client.get(url, format="json", **make_user_auth_headers(user, token))
results = response.json()["results"]
assert response.status_code == status.HTTP_200_OK
assert len(results) == 2
for result in results:
assert result["integration"] != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
@pytest.mark.django_db
@pytest.mark.parametrize(
"query_param,should_be_unpaginated",

View file

@ -43,6 +43,9 @@ class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
choices=AlertReceiveChannel.MAINTENANCE_MODE_CHOICES, method="filter_maintenance_mode"
)
integration = filters.MultipleChoiceFilter(choices=AlertReceiveChannel.INTEGRATION_CHOICES)
integration_ne = filters.MultipleChoiceFilter(
choices=AlertReceiveChannel.INTEGRATION_CHOICES, field_name="integration", exclude=True
)
team = TeamModelMultipleChoiceFilter()
class Meta:

View file

@ -36,6 +36,7 @@ module.exports = {
'newlines-between': 'always',
},
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': [
'warn',
{

View file

@ -15,7 +15,7 @@ test.describe("updating an integration's heartbeat interval works", async () =>
};
test('change heartbeat interval', async ({ adminRolePage: { page } }) => {
await createIntegration(page, generateRandomValue());
await createIntegration({ page, integrationName: generateRandomValue() });
await _openHeartbeatSettingsForm(page);
@ -43,7 +43,7 @@ test.describe("updating an integration's heartbeat interval works", async () =>
});
test('send heartbeat', async ({ adminRolePage: { page } }) => {
await createIntegration(page, generateRandomValue());
await createIntegration({ page, integrationName: generateRandomValue() });
await _openHeartbeatSettingsForm(page);

View file

@ -0,0 +1,47 @@
import { test, expect } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations';
test('Integrations table shows data in Connections and Direct Paging tabs', async ({ adminRolePage: { page } }) => {
// // Create 2 integrations that are not Direct Paging
const ID = generateRandomValue();
const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`;
const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`;
const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging`;
await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME });
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
await createIntegration({
page,
integrationSearchText: 'Alertmanager',
shouldGoToIntegrationsPage: false,
integrationName: ALERTMANAGER_INTEGRATION_NAME,
});
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// Create 1 Direct Paging integration if it doesn't exist
const integrationsTable = page.getByTestId('integrations-table');
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
const isDirectPagingAlreadyCreated = await page.getByText('Direct paging').isVisible();
if (!isDirectPagingAlreadyCreated) {
await createIntegration({
page,
integrationSearchText: 'Direct paging',
shouldGoToIntegrationsPage: false,
integrationName: DIRECT_PAGING_INTEGRATION_NAME,
});
}
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// By default Connections tab is opened and newly created integrations are visible except Direct Paging one
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable).not.toContainText(DIRECT_PAGING_INTEGRATION_NAME);
// Then after switching to Direct Paging tab only Direct Paging integration is visible
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable).toContainText(DIRECT_PAGING_INTEGRATION_NAME);
});

View file

@ -106,7 +106,7 @@ test.describe('maintenance mode works', () => {
const integrationName = generateRandomValue();
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegration(page, integrationName);
await createIntegration({ page, integrationName });
await assignEscalationChainToIntegration(page, escalationChainName);
await enableMaintenanceMode(page, maintenanceModeType);

View file

@ -32,7 +32,7 @@ export const createEscalationChain = async (
await page.locator('text=Loading...').waitFor({ state: 'detached' });
// open the create escalation chain modal
(await page.waitForSelector('text=New Escalation Chain')).click();
(await page.waitForSelector('text=/New Escalation Chain/i')).click();
// fill in the name input
await fillInInput(page, 'div[data-testid="create-escalation-chain-name-input-modal"] >> input', escalationChainName);
@ -44,7 +44,10 @@ export const createEscalationChain = async (
if (escalationStep) {
// add an escalation step
await selectDropdownValue({
page, selectType: 'grafanaSelect', placeholderText: 'Add escalation step...', value: escalationStep,
page,
selectType: 'grafanaSelect',
placeholderText: 'Add escalation step...',
value: escalationStep,
});
// toggle important
@ -52,13 +55,15 @@ export const createEscalationChain = async (
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: "Default",
value: "Important",
placeholderText: 'Default',
value: 'Important',
});
}
// select the escalation step value (e.g. user or schedule)
if (escalationStepValue) {await selectEscalationStepValue(page, escalationStep, escalationStepValue);}
if (escalationStepValue) {
await selectEscalationStepValue(page, escalationStep, escalationStepValue);
}
}
};

View file

@ -1,25 +1,40 @@
import { Page } from '@playwright/test';
import { clickButton, selectDropdownValue } from './forms';
import { clickButton, generateRandomValue, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]';
export const openCreateIntegrationModal = async (page: Page): Promise<void> => {
// go to the integrations page
await goToOnCallPage(page, 'integrations');
// open the create integration modal
(await page.waitForSelector('text=New integration')).click();
await page.getByRole('button', { name: 'New integration' }).click();
// wait for it to pop up
await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR);
await page.getByTestId('create-integration-modal').waitFor();
};
export const createIntegration = async (page: Page, integrationName: string): Promise<void> => {
export const createIntegration = async ({
page,
integrationName = `integration-${generateRandomValue()}`,
integrationSearchText = 'Webhook',
shouldGoToIntegrationsPage = true,
}: {
page: Page;
integrationName?: string;
integrationSearchText?: string;
shouldGoToIntegrationsPage?: boolean;
}): Promise<void> => {
if (shouldGoToIntegrationsPage) {
// go to the integrations page
await goToOnCallPage(page, 'integrations');
}
await openCreateIntegrationModal(page);
// create a webhook integration
(await page.waitForSelector(`${CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR} >> text=Webhook`)).click();
// create an integration
await page
.getByTestId('create-integration-modal')
.getByTestId('integration-display-name')
.filter({ hasText: integrationSearchText })
.first()
.click();
// fill in the required inputs
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName);
@ -55,7 +70,7 @@ export const createIntegrationAndSendDemoAlert = async (
integrationName: string,
escalationChainName: string
): Promise<void> => {
await createIntegration(page, integrationName);
await createIntegration({ page, integrationName });
await assignEscalationChainToIntegration(page, escalationChainName);
await sendDemoAlert(page);
};

View file

@ -13,6 +13,8 @@
"test:silent": "jest --silent",
"test:e2e": "yarn playwright test --grep-invert @expensive",
"test:e2e-expensive": "yarn playwright test --grep @expensive",
"test:e2e:watch": "yarn test:e2e --ui",
"test:e2e:gen": "yarn playwright codegen http://localhost:3000",
"cleanup-e2e-results": "rm -rf playwright-report && rm -rf test-results",
"e2e-show-report": "yarn playwright show-report",
"dev": "grafana-toolkit plugin:dev",

View file

@ -21,11 +21,8 @@ interface LabelsFilterProps {
const LabelsFilter = observer((props: LabelsFilterProps) => {
const { filterType, className, autoFocus, value: propsValue, onChange } = props;
const [value, setValue] = useState([]);
const [keys, setKeys] = useState([]);
const { alertGroupStore, labelsStore } = useStore();
const loadKeys =
@ -44,9 +41,7 @@ const LabelsFilter = observer((props: LabelsFilterProps) => {
useEffect(() => {
const keyValuePairs = (propsValue || []).map((k) => k.split(':'));
const promises = keyValuePairs.map(([keyId]) => loadValuesForKey(keyId));
const fetchKeyValues = async () => await Promise.all(promises);
fetchKeyValues().then((list) => {

View file

@ -44,6 +44,7 @@ interface RemoteFiltersProps extends WithStoreProps {
defaultFilters?: FiltersValues;
extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode;
grafanaTeamStore: GrafanaTeamStore;
skipFilterOptionFn?: (filterOption: FilterOption) => boolean;
}
interface RemoteFiltersState {
filterOptions?: FilterOption[];
@ -86,11 +87,16 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
page,
store: { filtersStore },
defaultFilters,
skipFilterOptionFn,
} = this.props;
const filterOptions = await filtersStore.updateOptionsForPage(page);
let filterOptions = await filtersStore.updateOptionsForPage(page);
const currentTablePageNum = parseInt(filtersStore.currentTablePageNum[page] || query.p || 1, 10);
if (skipFilterOptionFn) {
filterOptions = filterOptions.filter((option: FilterOption) => !skipFilterOptionFn(option));
}
// set the current page from filters/query or default it to 1
filtersStore.setCurrentTablePageNum(page, currentTablePageNum);

View file

@ -21,6 +21,7 @@ import {
AlertReceiveChannelCounters,
ContactPoint,
MaintenanceMode,
SupportedIntegrationFilters,
} from './alert_receive_channel.types';
export class AlertReceiveChannelStore extends BaseStore {
@ -132,8 +133,17 @@ export class AlertReceiveChannelStore extends BaseStore {
return results;
}
async updatePaginatedItems(query: any = '', page = 1, updateCounters = false, invalidateFn = undefined) {
const filters = typeof query === 'string' ? { search: query } : query;
async updatePaginatedItems({
filters,
page = 1,
updateCounters = false,
invalidateFn = undefined,
}: {
filters: SupportedIntegrationFilters;
page: number;
updateCounters: boolean;
invalidateFn: () => boolean;
}) {
const { count, results, page_size } = await makeRequest(this.path, { params: { ...filters, page } });
if (invalidateFn?.()) {

View file

@ -62,3 +62,11 @@ export interface ContactPoint {
contactPoint: string;
notificationConnected: boolean;
}
export interface SupportedIntegrationFilters {
integration?: string[];
integration_ne?: string[];
team?: string[];
label?: string[];
searchTerm?: string;
}

View file

@ -7,6 +7,10 @@
width: 40px;
}
.tabsBar {
margin-bottom: 24px;
}
.integrations-header {
margin-bottom: 24px;
right: 0;
@ -45,3 +49,7 @@
background: var(--cards-background);
}
}
.goToDirectPagingAlert {
margin-top: 24px;
}

View file

@ -1,7 +1,18 @@
import React from 'react';
import { LabelTag } from '@grafana/labels';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui';
import {
HorizontalGroup,
Button,
VerticalGroup,
Icon,
ConfirmModal,
Tooltip,
Tab,
TabsBar,
TabContent,
Alert,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -28,7 +39,11 @@ import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import {
AlertReceiveChannel,
MaintenanceMode,
SupportedIntegrationFilters,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelKeyValue } from 'models/label/label.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
@ -41,11 +56,29 @@ import { PAGE, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Integrations.module.scss';
enum TabType {
Connections = 'connections',
DirectPaging = 'direct-paging',
}
const TAB_QUERY_PARAM_KEY = 'tab';
const TABS = [
{
label: 'Connections',
value: TabType.Connections,
},
{
label: 'Direct Paging',
value: TabType.DirectPaging,
},
];
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Record<string, any>;
integrationsFilters: SupportedIntegrationFilters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
confirmationModal: {
isOpen: boolean;
@ -57,6 +90,7 @@ interface IntegrationsState extends PageBaseState {
confirmationText?: string;
onConfirm: () => void;
};
activeTab: TabType;
}
interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -67,9 +101,10 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
super(props);
this.state = {
integrationsFilters: { searchTerm: '' },
integrationsFilters: { searchTerm: '', integration_ne: ['direct_paging'] },
errorData: initErrorDataState(),
confirmationModal: undefined,
activeTab: props.query[TAB_QUERY_PARAM_KEY] || TabType.Connections,
};
}
@ -81,14 +116,12 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
if (prevProps.match.params.id !== this.props.match.params.id) {
this.parseQueryParams();
}
if (prevProps.query[TAB_QUERY_PARAM_KEY] !== this.props.query[TAB_QUERY_PARAM_KEY]) {
this.onTabChange(this.props.query[TAB_QUERY_PARAM_KEY] as TabType);
}
}
parseQueryParams = async () => {
this.setState((_prevState) => ({
errorData: initErrorDataState(),
alertReceiveChannelId: undefined,
})); // reset state on query parse
const {
store,
match: {
@ -96,6 +129,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
},
} = this.props;
this.setState((_prevState) => ({
errorData: initErrorDataState(),
alertReceiveChannelId: undefined,
})); // reset state on query parse
if (!id) {
return;
}
@ -114,24 +152,52 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
};
getFiltersBasedOnCurrentTab = () => ({
...this.state.integrationsFilters,
...(this.state.activeTab === TabType.DirectPaging
? { integration: ['direct_paging'] }
: {
integration_ne: ['direct_paging'],
integration: this.state.integrationsFilters.integration?.filter(
(integration) => integration !== 'direct_paging'
),
}),
});
update = () => {
const { store } = this.props;
const { integrationsFilters } = this.state;
const page = store.filtersStore.currentTablePageNum[PAGE.Integrations];
LocationHelper.update({ p: page }, 'partial');
return store.alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page, false, () =>
this.invalidateRequestFn(page)
return store.alertReceiveChannelStore.updatePaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page,
updateCounters: false,
invalidateFn: () => this.invalidateRequestFn(page),
});
};
onTabChange = (tab: TabType) => {
LocationHelper.update({ tab, integration: undefined, search: undefined }, 'partial');
this.setState(
{
activeTab: tab,
},
() => {
this.handleChangePage(1);
}
);
};
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, confirmationModal } = this.state;
const { alertReceiveChannelId, confirmationModal, activeTab, integrationsFilters } = this.state;
const { alertReceiveChannelStore } = store;
const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult();
const isDirectPagingSelectedOnConnectionsTab =
activeTab === TabType.Connections && integrationsFilters.integration?.includes('direct_paging');
return (
<>
@ -158,27 +224,58 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
</HorizontalGroup>
</div>
<div>
<RemoteFilters
query={query}
page={PAGE.Integrations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
/>
<GTable
emptyText={count === undefined ? 'Loading...' : 'No integrations found'}
loading={count === undefined}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={this.getTableColumns(store.hasFeature.bind(store))}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
page: store.filtersStore.currentTablePageNum[PAGE.Integrations],
total: results ? Math.ceil((count || 0) / page_size) : 0,
onChange: this.handleChangePage,
}}
/>
<TabsBar className={cx('tabsBar')}>
{TABS.map(({ label, value }) => (
<Tab
key={value}
label={label}
active={activeTab === value}
onChangeTab={() => this.onTabChange(value)}
/>
))}
</TabsBar>
<TabContent>
<RemoteFilters
key={activeTab} // added to remount the component on each tab
query={query}
page={PAGE.Integrations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
{...(activeTab === TabType.DirectPaging && {
skipFilterOptionFn: ({ name }) => name === 'integration',
})}
/>
{isDirectPagingSelectedOnConnectionsTab && (
<Alert
className={cx('goToDirectPagingAlert')}
severity="info"
title="Direct Paging integrations have been moved."
>
<span>
They are in a separate tab now. Go to{' '}
<PluginLink query={{ page: 'integrations', tab: TabType.DirectPaging }}>
Direct Paging tab
</PluginLink>{' '}
to view them.
</span>
</Alert>
)}
<GTable
emptyText={count === undefined ? 'Loading...' : 'No integrations found'}
loading={count === undefined}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={this.getTableColumns(store.hasFeature.bind(store))}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
page: store.filtersStore.currentTablePageNum[PAGE.Integrations],
total: results ? Math.ceil((count || 0) / page_size) : 0,
onChange: this.handleChangePage,
}}
/>
</TabContent>
</div>
</div>
@ -455,6 +552,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
getTableColumns = (hasFeatureFn) => {
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
const isConnectionsTab = this.state.activeTab === TabType.Connections;
const columns = [
{
@ -476,21 +574,24 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
},
...(isConnectionsTab
? [
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
]
: []),
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
width: isConnectionsTab ? '15%' : '30%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
@ -572,12 +673,15 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
applyFilters = async (isOnMount: boolean) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters } = this.state;
const newPage = isOnMount ? store.filtersStore.currentTablePageNum[PAGE.Integrations] : 1;
return alertReceiveChannelStore
.updatePaginatedItems(integrationsFilters, newPage, false, () => this.invalidateRequestFn(newPage))
.updatePaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page: newPage,
updateCounters: false,
invalidateFn: () => this.invalidateRequestFn(newPage),
})
.then(() => {
store.filtersStore.currentTablePageNum[PAGE.Integrations] = newPage;
LocationHelper.update({ p: newPage }, 'partial');