Alerting: Add export mute timings feature to the UI. (#79395)

* Add export for all muteTimings and a single muteTiming

* Add test

* Fix snapshot

* Fix test

* Add mute timing name in file name when exporting single mute timing
This commit is contained in:
Sonia Aguilar 2023-12-13 08:53:44 +01:00 committed by GitHub
parent 377262c283
commit 106903b549
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 268 additions and 10 deletions

View File

@ -239,5 +239,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({
method: 'POST',
}),
}),
exportMuteTimings: build.query<string, { format: ExportFormats }>({
query: ({ format }) => ({
url: `/api/v1/provisioning/mute-timings/export/`,
params: { format: format },
responseType: 'text',
}),
keepUnusedDataFor: 0,
}),
exportMuteTiming: build.query<string, { format: ExportFormats; muteTiming: string }>({
query: ({ format, muteTiming }) => ({
url: `/api/v1/provisioning/mute-timings/${muteTiming}/export/`,
params: { format: format },
responseType: 'text',
}),
keepUnusedDataFor: 0,
}),
}),
});

View File

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { alertRuleApi } from '../../api/alertRuleApi';
import { FileExportPreview } from './FileExportPreview';
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
import { allGrafanaExportProviders, ExportFormats } from './providers';
interface MuteTimingsExporterPreviewProps {
exportFormat: ExportFormats;
onClose: () => void;
}
const GrafanaMuteTimingsExporterPreview = ({ exportFormat, onClose }: MuteTimingsExporterPreviewProps) => {
const { currentData: muteTimingsDefinition = '', isFetching } = alertRuleApi.useExportMuteTimingsQuery({
format: exportFormat,
});
const downloadFileName = `mute-timings-${new Date().getTime()}`;
if (isFetching) {
return <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={muteTimingsDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};
interface GrafanaMuteTimingExporterPreviewProps extends MuteTimingsExporterPreviewProps {
muteTimingName: string;
}
const GrafanaMuteTimingExporterPreview = ({
exportFormat,
onClose,
muteTimingName,
}: GrafanaMuteTimingExporterPreviewProps) => {
const { currentData: muteTimingsDefinition = '', isFetching } = alertRuleApi.useExportMuteTimingQuery({
format: exportFormat,
muteTiming: muteTimingName,
});
const downloadFileName = `mute-timing-${muteTimingName}-${new Date().getTime()}`;
if (isFetching) {
return <LoadingPlaceholder text="Loading...." />;
}
return (
<FileExportPreview
format={exportFormat}
textDefinition={muteTimingsDefinition}
downloadFileName={downloadFileName}
onClose={onClose}
/>
);
};
interface GrafanaMuteTimingsExporterProps {
onClose: () => void;
muteTimingName?: string;
}
export const GrafanaMuteTimingsExporter = ({ onClose, muteTimingName }: GrafanaMuteTimingsExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
return (
<GrafanaExportDrawer
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)}
>
{muteTimingName ? (
<GrafanaMuteTimingExporterPreview exportFormat={activeTab} onClose={onClose} muteTimingName={muteTimingName} />
) : (
<GrafanaMuteTimingsExporterPreview exportFormat={activeTab} onClose={onClose} />
)}
</GrafanaExportDrawer>
);
};

View File

@ -0,0 +1,62 @@
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_DATASOURCE_NAME } from '../../utils/datasource';
import { MuteTimingsTable } from './MuteTimingsTable';
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
const renderWithProvider = (alertManagerSource?: string) => {
const store = configureStore();
return render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertManagerSource}>
<MuteTimingsTable alertManagerSourceName={alertManagerSource ?? GRAFANA_DATASOURCE_NAME} />
</AlertmanagerProvider>
</Router>
</Provider>
);
};
describe('MuteTimingsTable', () => {
it(' shows export button when allowed and supported', async () => {
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
const { findByRole } = renderWithProvider();
expect(await findByRole('button', { name: /export all/i })).toBeInTheDocument();
});
it('It does not show export button when not allowed ', async () => {
// when not allowed
grantUserPermissions([]);
const { queryByRole } = renderWithProvider();
await waitFor(() => {
expect(queryByRole('button', { name: /export all/i })).not.toBeInTheDocument();
});
});
it('It does not show export button when not supported ', async () => {
// when not supported
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
const { queryByRole } = renderWithProvider('potato');
await waitFor(() => {
expect(queryByRole('button', { name: /export all/i })).not.toBeInTheDocument();
});
});
});

View File

@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, LinkButton, Link, useStyles2, ConfirmModal, Stack } from '@grafana/ui';
import { Button, ConfirmModal, IconButton, Link, LinkButton, Menu, Stack, useStyles2 } from '@grafana/ui';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types/store';
@ -11,20 +12,56 @@ import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility }
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { deleteMuteTimingAction } from '../../state/actions';
import { makeAMLink } from '../../utils/misc';
import { DynamicTable, DynamicTableItemProps, DynamicTableColumnProps } from '../DynamicTable';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter';
import { renderTimeIntervals } from './util';
interface Props {
const ALL_MUTE_TIMINGS = Symbol('all mute timings');
type ExportProps = [JSX.Element | null, (muteTiming: string | typeof ALL_MUTE_TIMINGS) => void];
const useExportMuteTiming = (): ExportProps => {
const [muteTimingName, setMuteTimingName] = useState<string | typeof ALL_MUTE_TIMINGS | null>(null);
const [isExportDrawerOpen, toggleShowExportDrawer] = useToggle(false);
const handleClose = useCallback(() => {
setMuteTimingName(null);
toggleShowExportDrawer(false);
}, [toggleShowExportDrawer]);
const handleOpen = (receiverName: string | typeof ALL_MUTE_TIMINGS) => {
setMuteTimingName(receiverName);
toggleShowExportDrawer(true);
};
const drawer = useMemo(() => {
if (!muteTimingName || !isExportDrawerOpen) {
return null;
}
if (muteTimingName === ALL_MUTE_TIMINGS) {
// use this drawer when we want to export all mute timings
return <GrafanaMuteTimingsExporter onClose={handleClose} />;
} else {
// use this one for exporting a single mute timing
return <GrafanaMuteTimingsExporter muteTimingName={muteTimingName} onClose={handleClose} />;
}
}, [isExportDrawerOpen, handleClose, muteTimingName]);
return [drawer, handleOpen];
};
interface MuteTimingsTableProps {
alertManagerSourceName: string;
muteTimingNames?: string[];
hideActions?: boolean;
}
export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hideActions }: Props) => {
export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hideActions }: MuteTimingsTableProps) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
@ -53,9 +90,14 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
});
}, [config?.mute_time_intervals, config?.muteTimeProvenances, muteTimingNames]);
const columns = useColumns(alertManagerSourceName, hideActions, setMuteTimingName);
const [_, allowedToCreateMuteTiming] = useAlertmanagerAbility(AlertmanagerAction.CreateMuteTiming);
const [ExportDrawer, showExportDrawer] = useExportMuteTiming();
const [exportMuteTimingsSupported, exportMuteTimingsAllowed] = useAlertmanagerAbility(
AlertmanagerAction.ExportMuteTimings
);
const columns = useColumns(alertManagerSourceName, hideActions, setMuteTimingName, showExportDrawer);
return (
<div className={styles.container}>
<Stack direction="row" alignItems="center">
@ -67,7 +109,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
{!hideActions && items.length > 0 && (
<Authorize actions={[AlertmanagerAction.CreateMuteTiming]}>
<LinkButton
className={styles.addMuteButton}
className={styles.muteTimingsButtons}
icon="plus"
variant="primary"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
@ -76,6 +118,18 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
</LinkButton>
</Authorize>
)}
{exportMuteTimingsSupported && (
<Button
icon="download-alt"
className={styles.muteTimingsButtons}
variant="secondary"
aria-label="export all"
disabled={!exportMuteTimingsAllowed}
onClick={() => showExportDrawer(ALL_MUTE_TIMINGS)}
>
Export all
</Button>
)}
</Stack>
{items.length > 0 ? (
<DynamicTable items={items} cols={columns} pagination={{ itemsPerPage: 25 }} />
@ -104,17 +158,27 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
onDismiss={() => setMuteTimingName('')}
/>
)}
{ExportDrawer}
</div>
);
};
function useColumns(alertManagerSourceName: string, hideActions = false, setMuteTimingName: (name: string) => void) {
function useColumns(
alertManagerSourceName: string,
hideActions = false,
setMuteTimingName: (name: string) => void,
openExportDrawer: (muteTiming: string | typeof ALL_MUTE_TIMINGS) => void
) {
const [[_editSupported, allowedToEdit], [_deleteSupported, allowedToDelete]] = useAlertmanagerAbilities([
AlertmanagerAction.UpdateMuteTiming,
AlertmanagerAction.DeleteMuteTiming,
]);
const showActions = !hideActions && (allowedToEdit || allowedToDelete);
// const [ExportDrawer, openExportDrawer] = useExportMuteTiming();
// const [_, openExportDrawer] = useExportMuteTiming();
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportMuteTimings);
return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => {
const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [
{
@ -176,11 +240,32 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
</div>
);
},
size: '80px',
});
}
if (exportSupported) {
columns.push({
id: 'actions',
label: '',
renderCell: function renderActions({ data }) {
return (
<div>
<Menu.Item
icon="download-alt"
label="Export"
ariaLabel="export"
disabled={!exportAllowed}
data-testid="export"
onClick={() => openExportDrawer(data.name)}
/>
</div>
);
},
size: '100px',
});
}
return columns;
}, [alertManagerSourceName, setMuteTimingName, showActions]);
}, [alertManagerSourceName, setMuteTimingName, showActions, exportSupported, exportAllowed, openExportDrawer]);
}
const getStyles = (theme: GrafanaTheme2) => ({
@ -188,7 +273,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex;
flex-flow: column nowrap;
`,
addMuteButton: css`
muteTimingsButtons: css`
margin-bottom: ${theme.spacing(2)};
align-self: flex-end;
`,

View File

@ -54,6 +54,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a
false,
false,
],
"export-mute-timings": [
false,
false,
],
"export-notification-policies": [
false,
false,
@ -155,6 +159,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi
false,
true,
],
"export-mute-timings": [
false,
true,
],
"export-notification-policies": [
false,
true,
@ -256,6 +264,10 @@ exports[`alertmanager abilities should report everything is supported for builti
true,
true,
],
"export-mute-timings": [
true,
true,
],
"export-notification-policies": [
true,
true,

View File

@ -56,6 +56,7 @@ export enum AlertmanagerAction {
CreateMuteTiming = 'create-mute-timing',
UpdateMuteTiming = 'update-mute-timing',
DeleteMuteTiming = 'delete-mute-timing',
ExportMuteTimings = 'export-mute-timings',
}
// this enum lists all of the available actions we can take on a single alert rule
@ -235,6 +236,7 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
[AlertmanagerAction.ViewMuteTiming]: toAbility(AlwaysSupported, notificationsPermissions.read),
[AlertmanagerAction.UpdateMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.update),
[AlertmanagerAction.DeleteMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.delete),
[AlertmanagerAction.ExportMuteTimings]: toAbility(isGrafanaFlavoredAlertmanager, notificationsPermissions.read),
};
return abilities;