mirror of https://github.com/grafana/grafana
Alerting: Consume k8s Time Intervals API (#90094)
parent
d080a91e8a
commit
6c64d1d443
@ -0,0 +1,87 @@ |
||||
import { useState } from 'react'; |
||||
|
||||
import { Badge, ConfirmModal, LinkButton, Stack } from '@grafana/ui'; |
||||
import { Trans, t } from 'app/core/internationalization'; |
||||
import { useExportMuteTimingsDrawer } from 'app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer'; |
||||
|
||||
import { Authorize } from '../../components/Authorize'; |
||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||
import { makeAMLink } from '../../utils/misc'; |
||||
import { isDisabled } from '../../utils/mute-timings'; |
||||
|
||||
import { MuteTiming, useDeleteMuteTiming } from './useMuteTimings'; |
||||
|
||||
interface MuteTimingActionsButtonsProps { |
||||
muteTiming: MuteTiming; |
||||
alertManagerSourceName: string; |
||||
} |
||||
|
||||
export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }: MuteTimingActionsButtonsProps) => { |
||||
const deleteMuteTiming = useDeleteMuteTiming({ alertmanager: alertManagerSourceName! }); |
||||
const [showDeleteDrawer, setShowDeleteDrawer] = useState(false); |
||||
const [ExportDrawer, showExportDrawer] = useExportMuteTimingsDrawer(); |
||||
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportMuteTimings); |
||||
|
||||
const closeDeleteModal = () => setShowDeleteDrawer(false); |
||||
|
||||
const isGrafanaDataSource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; |
||||
const viewOrEditHref = makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { |
||||
muteName: muteTiming?.metadata?.name || muteTiming.name, |
||||
}); |
||||
|
||||
const viewOrEditButton = ( |
||||
<LinkButton href={viewOrEditHref} variant="secondary" size="sm" icon={muteTiming.provisioned ? 'eye' : 'pen'}> |
||||
{muteTiming.provisioned ? ( |
||||
<Trans i18nKey="alerting.common.view">View</Trans> |
||||
) : ( |
||||
<Trans i18nKey="alerting.common.edit">Edit</Trans> |
||||
)} |
||||
</LinkButton> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Stack direction="row" alignItems="center" justifyContent="flex-end" wrap="wrap"> |
||||
{!isGrafanaDataSource && isDisabled(muteTiming) && <Badge text="Disabled" color="orange" />} |
||||
<Authorize actions={[AlertmanagerAction.UpdateMuteTiming]}>{viewOrEditButton}</Authorize> |
||||
|
||||
{exportSupported && ( |
||||
<LinkButton |
||||
icon="download-alt" |
||||
variant="secondary" |
||||
size="sm" |
||||
data-testid="export" |
||||
disabled={!exportAllowed} |
||||
onClick={() => showExportDrawer(muteTiming.name)} |
||||
> |
||||
<Trans i18nKey="alerting.common.export">Export</Trans> |
||||
</LinkButton> |
||||
)} |
||||
|
||||
{!muteTiming.provisioned && ( |
||||
<Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}> |
||||
<LinkButton icon="trash-alt" variant="secondary" size="sm" onClick={() => setShowDeleteDrawer(true)}> |
||||
<Trans i18nKey="alerting.common.delete">Delete</Trans> |
||||
</LinkButton> |
||||
</Authorize> |
||||
)} |
||||
</Stack> |
||||
<ConfirmModal |
||||
isOpen={showDeleteDrawer} |
||||
title="Delete mute timing" |
||||
body={`Are you sure you would like to delete "${muteTiming.name}"?`} |
||||
confirmText={t('alerting.common.delete', 'Delete')} |
||||
onConfirm={async () => { |
||||
await deleteMuteTiming({ |
||||
name: muteTiming?.metadata?.name || muteTiming.name, |
||||
}); |
||||
|
||||
closeDeleteModal(); |
||||
}} |
||||
onDismiss={closeDeleteModal} |
||||
/> |
||||
{ExportDrawer} |
||||
</> |
||||
); |
||||
}; |
@ -1,61 +1,161 @@ |
||||
import { render, waitFor, screen } from '@testing-library/react'; |
||||
import { Provider } from 'react-redux'; |
||||
import { Router } from 'react-router-dom'; |
||||
import { render, screen, userEvent, within } from 'test/test-utils'; |
||||
|
||||
import { locationService } from '@grafana/runtime'; |
||||
import { configureStore } from 'app/store/configureStore'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { defaultConfig } from 'app/features/alerting/unified/MuteTimings.test'; |
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi'; |
||||
import { |
||||
setGrafanaAlertmanagerConfig, |
||||
setMuteTimingsListError, |
||||
} from 'app/features/alerting/unified/mocks/server/configure'; |
||||
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events'; |
||||
import { TIME_INTERVAL_UID_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/timeIntervals.k8s'; |
||||
import { AccessControlAction } from 'app/types'; |
||||
|
||||
import { grantUserPermissions } from '../../mocks'; |
||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; |
||||
import { GRAFANA_DATASOURCE_NAME } from '../../utils/datasource'; |
||||
import { GRAFANA_RULES_SOURCE_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> |
||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertManagerSource}> |
||||
<MuteTimingsTable alertManagerSourceName={alertManagerSource ?? GRAFANA_RULES_SOURCE_NAME} /> |
||||
</AlertmanagerProvider> |
||||
); |
||||
}; |
||||
|
||||
setupMswServer(); |
||||
|
||||
describe('MuteTimingsTable', () => { |
||||
it(' shows export button when allowed and supported', async () => { |
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingNotificationsRead, |
||||
AccessControlAction.AlertingNotificationsWrite, |
||||
]); |
||||
renderWithProvider(); |
||||
expect(await screen.findByRole('button', { name: /export all/i })).toBeInTheDocument(); |
||||
}); |
||||
it('It does not show export button when not allowed ', async () => { |
||||
// when not allowed
|
||||
grantUserPermissions([]); |
||||
renderWithProvider(); |
||||
await waitFor(() => { |
||||
describe('with necessary permissions', () => { |
||||
beforeEach(() => { |
||||
setGrafanaAlertmanagerConfig(defaultConfig); |
||||
config.featureToggles.alertingApiServer = false; |
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingNotificationsRead, |
||||
AccessControlAction.AlertingNotificationsWrite, |
||||
]); |
||||
}); |
||||
|
||||
it("shows 'export all' drawer when allowed and supported", async () => { |
||||
const user = userEvent.setup(); |
||||
renderWithProvider(); |
||||
await user.click(await screen.findByRole('button', { name: /export all/i })); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: /drawer title export/i })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it("shows individual 'export' drawer when allowed and supported, and can close", async () => { |
||||
const user = userEvent.setup(); |
||||
renderWithProvider(); |
||||
const table = await screen.findByTestId('dynamic-table'); |
||||
const exportMuteTiming = await within(table).findByText(/export/i); |
||||
await user.click(exportMuteTiming); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: /drawer title export/i })).toBeInTheDocument(); |
||||
|
||||
await user.click(screen.getByText(/cancel/i)); |
||||
|
||||
expect(screen.queryByRole('dialog', { name: /drawer title export/i })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('does not show export button when not supported', async () => { |
||||
renderWithProvider('potato'); |
||||
expect(screen.queryByRole('button', { name: /export all/i })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('deletes interval', async () => { |
||||
// TODO: Don't use captureRequests for this, move to stateful mock server instead
|
||||
// and check that the interval is no longer in the list
|
||||
const capture = captureRequests(); |
||||
const user = userEvent.setup(); |
||||
renderWithProvider(); |
||||
|
||||
await user.click((await screen.findAllByText(/delete/i))[0]); |
||||
await user.click(await screen.findByRole('button', { name: /delete/i })); |
||||
|
||||
const requests = await capture; |
||||
const amConfigUpdateRequest = requests.find( |
||||
(r) => r.url.includes('/alertmanager/grafana/config/api/v1/alerts') && r.method === 'POST' |
||||
); |
||||
|
||||
const body = await amConfigUpdateRequest?.clone().json(); |
||||
expect(body.alertmanager_config.mute_time_intervals).toHaveLength(0); |
||||
}); |
||||
|
||||
it('allow cancelling deletion', async () => { |
||||
// TODO: Don't use captureRequests for this, move to stateful mock server instead
|
||||
// and check that the interval is still in the list
|
||||
const capture = captureRequests(); |
||||
const user = userEvent.setup(); |
||||
renderWithProvider(); |
||||
|
||||
await user.click((await screen.findAllByText(/delete/i))[0]); |
||||
await user.click(await screen.findByRole('button', { name: /cancel/i })); |
||||
|
||||
const requests = await capture; |
||||
const amConfigUpdateRequest = requests.find( |
||||
(r) => r.url.includes('/alertmanager/grafana/config/api/v1/alerts') && r.method === 'POST' |
||||
); |
||||
|
||||
expect(amConfigUpdateRequest).toBeUndefined(); |
||||
}); |
||||
}); |
||||
it('It does not show export button when not supported ', async () => { |
||||
// when not supported
|
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingNotificationsRead, |
||||
AccessControlAction.AlertingNotificationsWrite, |
||||
]); |
||||
renderWithProvider('potato'); |
||||
await waitFor(() => { |
||||
|
||||
describe('without necessary permissions', () => { |
||||
beforeEach(() => { |
||||
grantUserPermissions([]); |
||||
}); |
||||
|
||||
it('does not show export button when not allowed ', async () => { |
||||
renderWithProvider(); |
||||
expect(screen.queryByRole('button', { name: /export all/i })).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('using alertingApiServer feature toggle', () => { |
||||
beforeEach(() => { |
||||
config.featureToggles.alertingApiServer = true; |
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingNotificationsRead, |
||||
AccessControlAction.AlertingNotificationsWrite, |
||||
]); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
config.featureToggles.alertingApiServer = false; |
||||
}); |
||||
|
||||
it('shows list of intervals from k8s API', async () => { |
||||
renderWithProvider(); |
||||
expect(await screen.findByTestId('dynamic-table')).toBeInTheDocument(); |
||||
|
||||
expect(await screen.findByText('Provisioned')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows error when mute timings cannot load', async () => { |
||||
setMuteTimingsListError(); |
||||
renderWithProvider(); |
||||
expect(await screen.findByText(/error loading mute timings/i)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('deletes interval', async () => { |
||||
// TODO: Don't use captureRequests for this, move to stateful mock server instead
|
||||
// and check that the interval is no longer in the list
|
||||
const capture = captureRequests(); |
||||
const user = userEvent.setup(); |
||||
renderWithProvider(); |
||||
|
||||
await user.click((await screen.findAllByText(/delete/i))[0]); |
||||
await user.click(await screen.findByRole('button', { name: /delete/i })); |
||||
|
||||
const requests = await capture; |
||||
const deleteRequest = requests.find( |
||||
(r) => r.url.includes(`timeintervals/${TIME_INTERVAL_UID_HAPPY_PATH}`) && r.method === 'DELETE' |
||||
); |
||||
|
||||
expect(deleteRequest).toBeDefined(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
@ -1,42 +1,64 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`renderTimeIntervals should render empty time interval 1`] = `[]`; |
||||
exports[`renderTimeIntervals should render empty time interval 1`] = ` |
||||
<Stack |
||||
direction="column" |
||||
gap={1} |
||||
/> |
||||
`; |
||||
|
||||
exports[`renderTimeIntervals should render time interval with kitchen sink 1`] = ` |
||||
[ |
||||
<Stack |
||||
direction="column" |
||||
gap={1} |
||||
> |
||||
<React.Fragment> |
||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||
<br /> |
||||
</React.Fragment>, |
||||
<div> |
||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||
<br /> |
||||
</div> |
||||
</React.Fragment> |
||||
<React.Fragment> |
||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||
<br /> |
||||
</React.Fragment>, |
||||
] |
||||
<div> |
||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||
<br /> |
||||
</div> |
||||
</React.Fragment> |
||||
</Stack> |
||||
`; |
||||
|
||||
exports[`renderTimeIntervals should render time interval with time range 1`] = ` |
||||
[ |
||||
<Stack |
||||
direction="column" |
||||
gap={1} |
||||
> |
||||
<React.Fragment> |
||||
Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All |
||||
<br /> |
||||
Days of the month: All | Months: All | Years: All |
||||
<br /> |
||||
</React.Fragment>, |
||||
] |
||||
<div> |
||||
Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All |
||||
<br /> |
||||
Days of the month: All | Months: All | Years: All |
||||
<br /> |
||||
</div> |
||||
</React.Fragment> |
||||
</Stack> |
||||
`; |
||||
|
||||
exports[`renderTimeIntervals should render time interval with weekdays 1`] = ` |
||||
[ |
||||
<Stack |
||||
direction="column" |
||||
gap={1} |
||||
> |
||||
<React.Fragment> |
||||
Times: All Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: All | Months: All | Years: All |
||||
<br /> |
||||
</React.Fragment>, |
||||
] |
||||
<div> |
||||
Times: All Weekdays: Mon, Tue-Thu, Sun |
||||
<br /> |
||||
Days of the month: All | Months: All | Years: All |
||||
<br /> |
||||
</div> |
||||
</React.Fragment> |
||||
</Stack> |
||||
`; |
||||
|
@ -0,0 +1,39 @@ |
||||
import { useCallback, useMemo, useState } from 'react'; |
||||
import { useToggle } from 'react-use'; |
||||
|
||||
import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter'; |
||||
|
||||
export const ALL_MUTE_TIMINGS = Symbol('all mute timings'); |
||||
|
||||
type ExportProps = [JSX.Element | null, (muteTiming: string | typeof ALL_MUTE_TIMINGS) => void]; |
||||
|
||||
export const useExportMuteTimingsDrawer = (): 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 = (muteTimingName: string | typeof ALL_MUTE_TIMINGS) => { |
||||
setMuteTimingName(muteTimingName); |
||||
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]; |
||||
}; |
@ -0,0 +1,345 @@ |
||||
import { produce } from 'immer'; |
||||
import { useEffect } from 'react'; |
||||
|
||||
import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; |
||||
import { timeIntervalsApi } from 'app/features/alerting/unified/api/timeIntervalsApi'; |
||||
import { |
||||
getK8sNamespace, |
||||
mergeTimeIntervals, |
||||
shouldUseK8sApi, |
||||
} from 'app/features/alerting/unified/components/mute-timings/util'; |
||||
import { |
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval, |
||||
ReadNamespacedTimeIntervalApiResponse, |
||||
} from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; |
||||
import { deleteMuteTimingAction, updateAlertManagerConfigAction } from 'app/features/alerting/unified/state/actions'; |
||||
import { renameMuteTimings } from 'app/features/alerting/unified/utils/alertmanager'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; |
||||
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; |
||||
import { useDispatch } from 'app/types'; |
||||
|
||||
const { useLazyGetAlertmanagerConfigurationQuery } = alertmanagerApi; |
||||
const { |
||||
useLazyListNamespacedTimeIntervalQuery, |
||||
useCreateNamespacedTimeIntervalMutation, |
||||
useLazyReadNamespacedTimeIntervalQuery, |
||||
useReplaceNamespacedTimeIntervalMutation, |
||||
useDeleteNamespacedTimeIntervalMutation, |
||||
} = timeIntervalsApi; |
||||
|
||||
type BaseAlertmanagerArgs = { |
||||
/** |
||||
* Name of alertmanager being used for mute timings management. |
||||
* |
||||
* Hooks will behave differently depending on whether this is `grafana` or an external alertmanager |
||||
*/ |
||||
alertmanager: string; |
||||
}; |
||||
|
||||
/** |
||||
* Alertmanager mute time interval, with optional additional metadata |
||||
* (returned in the case of K8S API implementation) |
||||
* */ |
||||
export type MuteTiming = MuteTimeInterval & { |
||||
id: string; |
||||
metadata?: ReadNamespacedTimeIntervalApiResponse['metadata']; |
||||
}; |
||||
|
||||
/** Name of the custom annotation label used in k8s APIs for us to discern if a given entity was provisioned */ |
||||
export const PROVENANCE_ANNOTATION = 'grafana.com/provenance'; |
||||
|
||||
/** Value of `PROVENANCE_ANNOTATION` given for non-provisioned intervals */ |
||||
export const PROVENANCE_NONE = 'none'; |
||||
|
||||
/** Alias for generated kuberenetes Alerting API Server type */ |
||||
type TimeIntervalV0Alpha1 = ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval; |
||||
|
||||
/** Parse kubernetes API response into a Mute Timing */ |
||||
const parseK8sTimeInterval: (item: TimeIntervalV0Alpha1) => MuteTiming = (item) => { |
||||
const { metadata, spec } = item; |
||||
return { |
||||
...spec, |
||||
id: spec.name, |
||||
metadata, |
||||
provisioned: metadata.annotations?.[PROVENANCE_ANNOTATION] !== PROVENANCE_NONE, |
||||
}; |
||||
}; |
||||
|
||||
/** Parse Alertmanager time interval response into a Mute Timing */ |
||||
const parseAmTimeInterval: (interval: MuteTimeInterval, provenance: string) => MuteTiming = (interval, provenance) => { |
||||
return { |
||||
...interval, |
||||
id: interval.name, |
||||
provisioned: Boolean(provenance && provenance !== PROVENANCE_NONE), |
||||
}; |
||||
}; |
||||
|
||||
const useAlertmanagerIntervals = () => |
||||
useLazyGetAlertmanagerConfigurationQuery({ |
||||
selectFromResult: ({ data, ...rest }) => { |
||||
if (!data) { |
||||
return { data, ...rest }; |
||||
} |
||||
const { alertmanager_config } = data; |
||||
const muteTimingsProvenances = alertmanager_config.muteTimeProvenances ?? {}; |
||||
const intervals = mergeTimeIntervals(alertmanager_config); |
||||
const timeIntervals = intervals.map((interval) => |
||||
parseAmTimeInterval(interval, muteTimingsProvenances[interval.name]) |
||||
); |
||||
|
||||
return { |
||||
data: timeIntervals, |
||||
...rest, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
const useGrafanaAlertmanagerIntervals = () => |
||||
useLazyListNamespacedTimeIntervalQuery({ |
||||
selectFromResult: ({ data, ...rest }) => { |
||||
return { |
||||
data: data?.items.map((item) => parseK8sTimeInterval(item)), |
||||
...rest, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
/** |
||||
* Depending on alertmanager source, fetches mute timings. |
||||
* |
||||
* If the alertmanager source is Grafana, and `alertingApiServer` feature toggle is enabled, |
||||
* fetches time intervals from k8s API. |
||||
* |
||||
* Otherwise, fetches and parses from the alertmanager config API |
||||
*/ |
||||
export const useMuteTimings = ({ alertmanager }: BaseAlertmanagerArgs) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const [getGrafanaTimeIntervals, intervalsResponse] = useGrafanaAlertmanagerIntervals(); |
||||
const [getAlertmanagerTimeIntervals, configApiResponse] = useAlertmanagerIntervals(); |
||||
|
||||
useEffect(() => { |
||||
if (useK8sApi) { |
||||
const namespace = getK8sNamespace(); |
||||
getGrafanaTimeIntervals({ namespace }); |
||||
} else { |
||||
getAlertmanagerTimeIntervals(alertmanager); |
||||
} |
||||
}, [alertmanager, getAlertmanagerTimeIntervals, getGrafanaTimeIntervals, useK8sApi]); |
||||
return useK8sApi ? intervalsResponse : configApiResponse; |
||||
}; |
||||
|
||||
/** |
||||
* Create a new mute timing. |
||||
* |
||||
* If the alertmanager source is Grafana, and `alertingApiServer` feature toggle is enabled, |
||||
* fetches time intervals from k8s API. |
||||
* |
||||
* Otherwise, creates the new timing in `time_intervals` via AM config API |
||||
*/ |
||||
export const useCreateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const dispatch = useDispatch(); |
||||
const [createGrafanaTimeInterval] = useCreateNamespacedTimeIntervalMutation(); |
||||
const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery(); |
||||
|
||||
const isGrafanaAm = alertmanager === GRAFANA_RULES_SOURCE_NAME; |
||||
|
||||
if (useK8sApi) { |
||||
const namespace = getK8sNamespace(); |
||||
return ({ timeInterval }: { timeInterval: MuteTimeInterval }) => |
||||
createGrafanaTimeInterval({ |
||||
namespace, |
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: { metadata: {}, spec: timeInterval }, |
||||
}).unwrap(); |
||||
} |
||||
|
||||
return async ({ timeInterval }: { timeInterval: MuteTimeInterval }) => { |
||||
const result = await getAlertmanagerConfig(alertmanager).unwrap(); |
||||
const newConfig = produce(result, (draft) => { |
||||
const propertyToUpdate = isGrafanaAm ? 'mute_time_intervals' : 'time_intervals'; |
||||
draft.alertmanager_config[propertyToUpdate] = draft.alertmanager_config[propertyToUpdate] ?? []; |
||||
draft.alertmanager_config[propertyToUpdate] = (draft.alertmanager_config[propertyToUpdate] ?? []).concat( |
||||
timeInterval |
||||
); |
||||
}); |
||||
|
||||
return dispatch( |
||||
updateAlertManagerConfigAction({ |
||||
newConfig, |
||||
oldConfig: result, |
||||
alertManagerSourceName: alertmanager, |
||||
successMessage: 'Mute timing saved', |
||||
}) |
||||
).unwrap(); |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Get an individual time interval, either from the k8s API, |
||||
* or by finding it in the alertmanager config |
||||
*/ |
||||
export const useGetMuteTiming = ({ alertmanager, name: nameToFind }: BaseAlertmanagerArgs & { name: string }) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const [getGrafanaTimeInterval, k8sResponse] = useLazyReadNamespacedTimeIntervalQuery({ |
||||
selectFromResult: ({ data, ...rest }) => { |
||||
if (!data) { |
||||
return { data, ...rest }; |
||||
} |
||||
|
||||
return { |
||||
data: parseK8sTimeInterval(data), |
||||
...rest, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
const [getAlertmanagerTimeInterval, amConfigApiResponse] = useLazyGetAlertmanagerConfigurationQuery({ |
||||
selectFromResult: ({ data, ...rest }) => { |
||||
if (!data) { |
||||
return { data, ...rest }; |
||||
} |
||||
const alertmanager_config = data?.alertmanager_config ?? {}; |
||||
const timeIntervals = mergeTimeIntervals(alertmanager_config); |
||||
const timing = timeIntervals.find(({ name }) => name === nameToFind); |
||||
if (timing) { |
||||
const muteTimingsProvenances = alertmanager_config?.muteTimeProvenances ?? {}; |
||||
|
||||
return { |
||||
data: parseAmTimeInterval(timing, muteTimingsProvenances[timing.name]), |
||||
...rest, |
||||
}; |
||||
} |
||||
return { ...rest, data: undefined, isError: true }; |
||||
}, |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (useK8sApi) { |
||||
const namespace = getK8sNamespace(); |
||||
getGrafanaTimeInterval({ namespace, name: nameToFind }, true); |
||||
} else { |
||||
getAlertmanagerTimeInterval(alertmanager, true); |
||||
} |
||||
}, [alertmanager, getAlertmanagerTimeInterval, getGrafanaTimeInterval, nameToFind, useK8sApi]); |
||||
|
||||
return useK8sApi ? k8sResponse : amConfigApiResponse; |
||||
}; |
||||
|
||||
/** |
||||
* Updates an existing mute timing. |
||||
* |
||||
* If the alertmanager source is Grafana, and `alertingApiServer` feature toggle is enabled, |
||||
* uses the k8s API. At the time of writing, the name of the timing cannot be changed via this API |
||||
* |
||||
* Otherwise, updates the timing via AM config API, and also ensures any referenced routes are updated |
||||
*/ |
||||
export const useUpdateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const dispatch = useDispatch(); |
||||
const [replaceGrafanaTimeInterval] = useReplaceNamespacedTimeIntervalMutation(); |
||||
const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery(); |
||||
|
||||
if (useK8sApi) { |
||||
return async ({ timeInterval, originalName }: { timeInterval: MuteTimeInterval; originalName: string }) => { |
||||
const namespace = getK8sNamespace(); |
||||
return replaceGrafanaTimeInterval({ |
||||
name: originalName, |
||||
namespace, |
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: { |
||||
spec: timeInterval, |
||||
metadata: { name: originalName }, |
||||
}, |
||||
}).unwrap(); |
||||
}; |
||||
} |
||||
|
||||
return async ({ timeInterval, originalName }: { timeInterval: MuteTimeInterval; originalName: string }) => { |
||||
const nameHasChanged = timeInterval.name !== originalName; |
||||
const result = await getAlertmanagerConfig(alertmanager).unwrap(); |
||||
|
||||
const newConfig = produce(result, (draft) => { |
||||
const existingIntervalIndex = (draft.alertmanager_config?.time_intervals || [])?.findIndex( |
||||
({ name }) => name === originalName |
||||
); |
||||
if (existingIntervalIndex !== -1) { |
||||
draft.alertmanager_config.time_intervals![existingIntervalIndex] = timeInterval; |
||||
} |
||||
|
||||
const existingMuteIntervalIndex = (draft.alertmanager_config?.mute_time_intervals || [])?.findIndex( |
||||
({ name }) => name === originalName |
||||
); |
||||
if (existingMuteIntervalIndex !== -1) { |
||||
draft.alertmanager_config.mute_time_intervals![existingMuteIntervalIndex] = timeInterval; |
||||
} |
||||
|
||||
if (nameHasChanged && draft.alertmanager_config.route) { |
||||
draft.alertmanager_config.route = renameMuteTimings( |
||||
timeInterval.name, |
||||
originalName, |
||||
draft.alertmanager_config.route |
||||
); |
||||
} |
||||
}); |
||||
|
||||
return dispatch( |
||||
updateAlertManagerConfigAction({ |
||||
newConfig, |
||||
oldConfig: result, |
||||
alertManagerSourceName: alertmanager, |
||||
successMessage: 'Mute timing saved', |
||||
}) |
||||
).unwrap(); |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Delete a mute timing interval |
||||
*/ |
||||
export const useDeleteMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const dispatch = useDispatch(); |
||||
const [deleteGrafanaTimeInterval] = useDeleteNamespacedTimeIntervalMutation(); |
||||
|
||||
if (useK8sApi) { |
||||
return async ({ name }: { name: string }) => { |
||||
const namespace = getK8sNamespace(); |
||||
return deleteGrafanaTimeInterval({ |
||||
name, |
||||
namespace, |
||||
ioK8SApimachineryPkgApisMetaV1DeleteOptions: {}, |
||||
}).unwrap(); |
||||
}; |
||||
} |
||||
|
||||
return async ({ name }: { name: string }) => dispatch(deleteMuteTimingAction(alertmanager, name)); |
||||
}; |
||||
|
||||
export const useValidateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => { |
||||
const useK8sApi = shouldUseK8sApi(alertmanager); |
||||
|
||||
const [getIntervals] = useAlertmanagerIntervals(); |
||||
|
||||
// If we're using the kubernetes API, then we let the API response handle the validation instead
|
||||
// as we don't expect to be able to fetch the intervals via the AM config
|
||||
if (useK8sApi) { |
||||
return () => undefined; |
||||
} |
||||
|
||||
return async (value: string, skipValidation?: boolean) => { |
||||
if (skipValidation) { |
||||
return; |
||||
} |
||||
return getIntervals(alertmanager) |
||||
.unwrap() |
||||
.then((config) => { |
||||
const intervals = mergeTimeIntervals(config.alertmanager_config); |
||||
const duplicatedInterval = Boolean(intervals?.find((interval) => interval.name === value)); |
||||
return duplicatedInterval ? `Mute timing already exists with name "${value}"` : undefined; |
||||
}); |
||||
}; |
||||
}; |
@ -0,0 +1,21 @@ |
||||
// POST /apis/notifications.alerting.grafana.app/v0alpha1/namespaces/default/timeintervals
|
||||
|
||||
import { HttpResponse, HttpResponseResolver, http } from 'msw'; |
||||
|
||||
const getProvisioningHelper: HttpResponseResolver = ({ request }) => { |
||||
const url = new URL(request.url); |
||||
const format = url.searchParams.get('format'); |
||||
if (format === 'yaml') { |
||||
// TODO: Return realistic mocked YAML
|
||||
return HttpResponse.text('', { headers: { 'Content-Type': 'text/yaml' } }); |
||||
} |
||||
// TODO: Return realistic mocked JSON
|
||||
return HttpResponse.json({}); |
||||
}; |
||||
|
||||
const exportMuteTimingsHandler = () => http.get('/api/v1/provisioning/mute-timings/export', getProvisioningHelper); |
||||
const exportSpecificMuteTimingsHandler = () => |
||||
http.get('/api/v1/provisioning/mute-timings/:name/export', getProvisioningHelper); |
||||
|
||||
const handlers = [exportMuteTimingsHandler(), exportSpecificMuteTimingsHandler()]; |
||||
export default handlers; |
@ -0,0 +1,123 @@ |
||||
import { HttpResponse, http } from 'msw'; |
||||
|
||||
import { |
||||
PROVENANCE_ANNOTATION, |
||||
PROVENANCE_NONE, |
||||
} from 'app/features/alerting/unified/components/mute-timings/useMuteTimings'; |
||||
import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; |
||||
|
||||
const baseUrl = '/apis/notifications.alerting.grafana.app/v0alpha1'; |
||||
|
||||
const getK8sResponse = <T>(kind: string, items: T[]) => { |
||||
return { |
||||
kind, |
||||
apiVersion: 'notifications.alerting.grafana.app/v0alpha1', |
||||
metadata: {}, |
||||
items, |
||||
}; |
||||
}; |
||||
|
||||
/** UID of a time interval that we expect to follow all happy paths within tests/mocks */ |
||||
export const TIME_INTERVAL_UID_HAPPY_PATH = 'f4eae7a4895fa786'; |
||||
/** UID of a (file) provisioned time interval */ |
||||
export const TIME_INTERVAL_UID_FILE_PROVISIONED = 'd7b8515fc39e90f7'; |
||||
|
||||
const allTimeIntervals = getK8sResponse<ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval>( |
||||
'TimeIntervalList', |
||||
[ |
||||
{ |
||||
metadata: { |
||||
annotations: { |
||||
[PROVENANCE_ANNOTATION]: PROVENANCE_NONE, |
||||
}, |
||||
name: TIME_INTERVAL_UID_HAPPY_PATH, |
||||
uid: TIME_INTERVAL_UID_HAPPY_PATH, |
||||
namespace: 'default', |
||||
resourceVersion: 'e0270bfced786660', |
||||
}, |
||||
spec: { name: 'Some interval', time_intervals: [] }, |
||||
}, |
||||
{ |
||||
metadata: { |
||||
annotations: { |
||||
[PROVENANCE_ANNOTATION]: 'file', |
||||
}, |
||||
name: TIME_INTERVAL_UID_FILE_PROVISIONED, |
||||
uid: TIME_INTERVAL_UID_FILE_PROVISIONED, |
||||
namespace: 'default', |
||||
resourceVersion: 'a76d2fcc6731aa0c', |
||||
}, |
||||
spec: { name: 'A provisioned interval', time_intervals: [] }, |
||||
}, |
||||
] |
||||
); |
||||
|
||||
const getIntervalByName = (name: string) => { |
||||
return allTimeIntervals.items.find((interval) => interval.metadata.name === name); |
||||
}; |
||||
|
||||
export const listNamespacedTimeIntervalHandler = () => |
||||
http.get<{ namespace: string }>(`${baseUrl}/namespaces/:namespace/timeintervals`, ({ params }) => { |
||||
const { namespace } = params; |
||||
|
||||
// k8s APIs expect `default` rather than `org-1` - this is one particular example
|
||||
// to make sure we're performing the correct logic when calling this API
|
||||
if (namespace === 'org-1') { |
||||
return HttpResponse.json( |
||||
{ |
||||
message: 'error reading namespace: use default rather than org-1', |
||||
}, |
||||
{ status: 403 } |
||||
); |
||||
} |
||||
return HttpResponse.json(allTimeIntervals); |
||||
}); |
||||
|
||||
const readNamespacedTimeIntervalHandler = () => |
||||
http.get<{ namespace: string; name: string }>( |
||||
`${baseUrl}/namespaces/:namespace/timeintervals/:name`, |
||||
({ params }) => { |
||||
const { name } = params; |
||||
|
||||
const matchingInterval = getIntervalByName(name); |
||||
if (!matchingInterval) { |
||||
return HttpResponse.json({}, { status: 404 }); |
||||
} |
||||
return HttpResponse.json(matchingInterval); |
||||
} |
||||
); |
||||
|
||||
const replaceNamespacedTimeIntervalHandler = () => |
||||
http.put<{ namespace: string; name: string }>( |
||||
`${baseUrl}/namespaces/:namespace/timeintervals/:name`, |
||||
async ({ params, request }) => { |
||||
const { name } = params; |
||||
|
||||
const matchingInterval = allTimeIntervals.items.find((interval) => interval.metadata.name === name); |
||||
if (!matchingInterval) { |
||||
return HttpResponse.json({}, { status: 404 }); |
||||
} |
||||
|
||||
const body = await request.clone().json(); |
||||
return HttpResponse.json(body); |
||||
} |
||||
); |
||||
|
||||
const createNamespacedTimeIntervalHandler = () => |
||||
http.post<{ namespace: string }>(`${baseUrl}/namespaces/:namespace/timeintervals`, () => { |
||||
return HttpResponse.json({}); |
||||
}); |
||||
|
||||
const deleteNamespacedTimeIntervalHandler = () => |
||||
http.delete<{ namespace: string }>(`${baseUrl}/namespaces/:namespace/timeintervals/:name`, () => { |
||||
return HttpResponse.json({}); |
||||
}); |
||||
|
||||
const handlers = [ |
||||
listNamespacedTimeIntervalHandler(), |
||||
readNamespacedTimeIntervalHandler(), |
||||
replaceNamespacedTimeIntervalHandler(), |
||||
createNamespacedTimeIntervalHandler(), |
||||
deleteNamespacedTimeIntervalHandler(), |
||||
]; |
||||
export default handlers; |
Loading…
Reference in new issue