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 { render, screen, userEvent, within } from 'test/test-utils'; |
||||||
import { Provider } from 'react-redux'; |
|
||||||
import { Router } from 'react-router-dom'; |
|
||||||
|
|
||||||
import { locationService } from '@grafana/runtime'; |
import { config } from '@grafana/runtime'; |
||||||
import { configureStore } from 'app/store/configureStore'; |
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 { AccessControlAction } from 'app/types'; |
||||||
|
|
||||||
import { grantUserPermissions } from '../../mocks'; |
import { grantUserPermissions } from '../../mocks'; |
||||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; |
import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; |
||||||
import { GRAFANA_DATASOURCE_NAME } from '../../utils/datasource'; |
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||||
|
|
||||||
import { MuteTimingsTable } from './MuteTimingsTable'; |
import { MuteTimingsTable } from './MuteTimingsTable'; |
||||||
|
|
||||||
jest.mock('app/types', () => ({ |
|
||||||
...jest.requireActual('app/types'), |
|
||||||
useDispatch: () => jest.fn(), |
|
||||||
})); |
|
||||||
const renderWithProvider = (alertManagerSource?: string) => { |
const renderWithProvider = (alertManagerSource?: string) => { |
||||||
const store = configureStore(); |
|
||||||
|
|
||||||
return render( |
return render( |
||||||
<Provider store={store}> |
|
||||||
<Router history={locationService.getHistory()}> |
|
||||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertManagerSource}> |
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertManagerSource}> |
||||||
<MuteTimingsTable alertManagerSourceName={alertManagerSource ?? GRAFANA_DATASOURCE_NAME} /> |
<MuteTimingsTable alertManagerSourceName={alertManagerSource ?? GRAFANA_RULES_SOURCE_NAME} /> |
||||||
</AlertmanagerProvider> |
</AlertmanagerProvider> |
||||||
</Router> |
|
||||||
</Provider> |
|
||||||
); |
); |
||||||
}; |
}; |
||||||
|
|
||||||
|
setupMswServer(); |
||||||
|
|
||||||
describe('MuteTimingsTable', () => { |
describe('MuteTimingsTable', () => { |
||||||
it(' shows export button when allowed and supported', async () => { |
describe('with necessary permissions', () => { |
||||||
|
beforeEach(() => { |
||||||
|
setGrafanaAlertmanagerConfig(defaultConfig); |
||||||
|
config.featureToggles.alertingApiServer = false; |
||||||
grantUserPermissions([ |
grantUserPermissions([ |
||||||
AccessControlAction.AlertingNotificationsRead, |
AccessControlAction.AlertingNotificationsRead, |
||||||
AccessControlAction.AlertingNotificationsWrite, |
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(); |
renderWithProvider(); |
||||||
expect(await screen.findByRole('button', { name: /export all/i })).toBeInTheDocument(); |
|
||||||
|
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 allowed ', async () => { |
|
||||||
// when not allowed
|
describe('without necessary permissions', () => { |
||||||
|
beforeEach(() => { |
||||||
grantUserPermissions([]); |
grantUserPermissions([]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('does not show export button when not allowed ', async () => { |
||||||
renderWithProvider(); |
renderWithProvider(); |
||||||
await waitFor(() => { |
|
||||||
expect(screen.queryByRole('button', { name: /export all/i })).not.toBeInTheDocument(); |
expect(screen.queryByRole('button', { name: /export all/i })).not.toBeInTheDocument(); |
||||||
}); |
}); |
||||||
}); |
}); |
||||||
it('It does not show export button when not supported ', async () => { |
|
||||||
// when not supported
|
describe('using alertingApiServer feature toggle', () => { |
||||||
|
beforeEach(() => { |
||||||
|
config.featureToggles.alertingApiServer = true; |
||||||
grantUserPermissions([ |
grantUserPermissions([ |
||||||
AccessControlAction.AlertingNotificationsRead, |
AccessControlAction.AlertingNotificationsRead, |
||||||
AccessControlAction.AlertingNotificationsWrite, |
AccessControlAction.AlertingNotificationsWrite, |
||||||
]); |
]); |
||||||
renderWithProvider('potato'); |
}); |
||||||
await waitFor(() => { |
|
||||||
expect(screen.queryByRole('button', { name: /export all/i })).not.toBeInTheDocument(); |
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 |
// 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`] = ` |
exports[`renderTimeIntervals should render time interval with kitchen sink 1`] = ` |
||||||
[ |
<Stack |
||||||
|
direction="column" |
||||||
|
gap={1} |
||||||
|
> |
||||||
<React.Fragment> |
<React.Fragment> |
||||||
|
<div> |
||||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||||
<br /> |
<br /> |
||||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||||
<br /> |
<br /> |
||||||
</React.Fragment>, |
</div> |
||||||
|
</React.Fragment> |
||||||
<React.Fragment> |
<React.Fragment> |
||||||
|
<div> |
||||||
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun |
||||||
<br /> |
<br /> |
||||||
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 |
||||||
<br /> |
<br /> |
||||||
</React.Fragment>, |
</div> |
||||||
] |
</React.Fragment> |
||||||
|
</Stack> |
||||||
`; |
`; |
||||||
|
|
||||||
exports[`renderTimeIntervals should render time interval with time range 1`] = ` |
exports[`renderTimeIntervals should render time interval with time range 1`] = ` |
||||||
[ |
<Stack |
||||||
|
direction="column" |
||||||
|
gap={1} |
||||||
|
> |
||||||
<React.Fragment> |
<React.Fragment> |
||||||
|
<div> |
||||||
Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All |
Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All |
||||||
<br /> |
<br /> |
||||||
Days of the month: All | Months: All | Years: All |
Days of the month: All | Months: All | Years: All |
||||||
<br /> |
<br /> |
||||||
</React.Fragment>, |
</div> |
||||||
] |
</React.Fragment> |
||||||
|
</Stack> |
||||||
`; |
`; |
||||||
|
|
||||||
exports[`renderTimeIntervals should render time interval with weekdays 1`] = ` |
exports[`renderTimeIntervals should render time interval with weekdays 1`] = ` |
||||||
[ |
<Stack |
||||||
|
direction="column" |
||||||
|
gap={1} |
||||||
|
> |
||||||
<React.Fragment> |
<React.Fragment> |
||||||
|
<div> |
||||||
Times: All Weekdays: Mon, Tue-Thu, Sun |
Times: All Weekdays: Mon, Tue-Thu, Sun |
||||||
<br /> |
<br /> |
||||||
Days of the month: All | Months: All | Years: All |
Days of the month: All | Months: All | Years: All |
||||||
<br /> |
<br /> |
||||||
</React.Fragment>, |
</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