Alerting: Consume k8s Time Intervals API (#90094)

This commit is contained in:
Tom Ratcliffe 2024-07-31 14:59:15 +01:00 committed by GitHub
parent d080a91e8a
commit 6c64d1d443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1719 additions and 857 deletions

View File

@ -1757,10 +1757,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPointMenu.tsx:5381": [
[0, 0, 0, "Do not re-export imported variable (\`app/features/explore/extensions/ToolbarExtensionPointMenu\`)", "0"]
],
"public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@ -1771,8 +1767,7 @@ exports[`better eslint`] = {
],
"public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]

View File

@ -1,39 +1,47 @@
import { fireEvent, render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProvider } from 'test/helpers/TestProvider';
import { InitialEntry } from 'history';
import { Route } from 'react-router';
import { render, within, userEvent, screen } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
setAlertmanagerConfig,
setGrafanaAlertmanagerConfig,
} from 'app/features/alerting/unified/mocks/server/configure';
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
import { MOCK_DATASOURCE_EXTERNAL_VANILLA_ALERTMANAGER_UID } from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import {
TIME_INTERVAL_UID_HAPPY_PATH,
TIME_INTERVAL_UID_FILE_PROVISIONED,
} from 'app/features/alerting/unified/mocks/server/handlers/timeIntervals.k8s';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import MuteTimings from './MuteTimings';
import { fetchAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks';
import { DataSourceType } from './utils/datasource';
import { grantUserPermissions, mockDataSource } from './mocks';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./api/alertmanager');
const mocks = {
api: {
fetchAlertManagerConfig: jest.mocked(fetchAlertManagerConfig),
updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig),
},
};
const renderMuteTimings = (location = '/alerting/routes/mute-timing/new') => {
locationService.push(location);
return render(
<TestProvider>
const indexPageText = 'redirected routes page';
const renderMuteTimings = (location: InitialEntry = '/alerting/routes/mute-timing/new') => {
render(
<>
<Route path="/alerting/routes" exact>
{indexPageText}
</Route>
<MuteTimings />
</TestProvider>
</>,
{ historyOptions: { initialEntries: [location] } }
);
};
const alertmanagerName = 'alertmanager';
const dataSources = {
am: mockDataSource({
name: 'Alertmanager',
name: alertmanagerName,
uid: MOCK_DATASOURCE_EXTERNAL_VANILLA_ALERTMANAGER_UID,
type: DataSourceType.Alertmanager,
}),
};
@ -86,7 +94,8 @@ const muteTimeInterval2: MuteTimeInterval = {
],
};
const defaultConfig: AlertManagerCortexConfig = {
/** Alertmanager config where time intervals are stored in `mute_time_intervals` property */
export const defaultConfig: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
route: {
@ -104,6 +113,8 @@ const defaultConfig: AlertManagerCortexConfig = {
},
template_files: {},
};
/** Alertmanager config where time intervals are stored in `time_intervals` property */
const defaultConfigWithNewTimeIntervalsField: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
@ -123,6 +134,7 @@ const defaultConfigWithNewTimeIntervalsField: AlertManagerCortexConfig = {
template_files: {},
};
/** Alertmanager config where time intervals are stored in both `time_intervals` and `mute_time_intervals` properties */
const defaultConfigWithBothTimeIntervalsField: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
@ -143,169 +155,136 @@ const defaultConfigWithBothTimeIntervalsField: AlertManagerCortexConfig = {
template_files: {},
};
const resetMocks = () => {
jest.resetAllMocks();
const expectedToHaveRedirectedToRoutesRoute = async () =>
expect(await screen.findByText(indexPageText)).toBeInTheDocument();
mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
return Promise.resolve({ ...defaultConfig });
});
mocks.api.updateAlertManagerConfig.mockImplementation(() => {
return Promise.resolve();
});
const fillOutForm = async ({
name,
startsAt,
endsAt,
days,
months,
years,
}: {
name?: string;
startsAt?: string;
endsAt?: string;
days?: string;
months?: string;
years?: string;
}) => {
const user = userEvent.setup();
name && (await user.type(ui.nameField.get(), name));
startsAt && (await user.type(ui.startsAt.get(), startsAt));
endsAt && (await user.type(ui.endsAt.get(), endsAt));
days && (await user.type(ui.days.get(), days));
months && (await user.type(ui.months.get(), months));
years && (await user.type(ui.years.get(), years));
};
const saveMuteTiming = async () => {
const user = userEvent.setup();
await user.click(screen.getByText(/save mute timing/i));
};
setupMswServer();
const getAlertmanagerConfigUpdate = async (requests: Request[]) => {
const alertmanagerUpdate = requests.find(
(r) => r.url.match('/alertmanager/(.*)/config/api/v1/alert') && r.method === 'POST'
);
return alertmanagerUpdate!.clone().json();
};
describe('Mute timings', () => {
beforeEach(() => {
setDataSourceSrv(new MockDataSourceSrv(dataSources));
resetMocks();
setupDataSources(dataSources.am);
// FIXME: scope down
grantUserPermissions(Object.values(AccessControlAction));
setGrafanaAlertmanagerConfig(defaultConfig);
setAlertmanagerConfig(defaultConfig);
});
it('creates a new mute timing, with mute_time_intervals in config', async () => {
const capture = captureRequests();
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
await screen.findByText(/create mute timing/i);
await userEvent.type(ui.nameField.get(), 'maintenance period');
await userEvent.type(ui.startsAt.get(), '22:00');
await userEvent.type(ui.endsAt.get(), '24:00');
await userEvent.type(ui.days.get(), '-1');
await userEvent.type(ui.months.get(), 'january, july');
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
{
name: 'maintenance period',
time_intervals: [
{
days_of_month: ['-1'],
months: ['january', 'july'],
times: [
{
start_time: '22:00',
end_time: '24:00',
},
],
},
],
},
],
},
await fillOutForm({
name: 'maintenance period',
startsAt: '22:00',
endsAt: '24:00',
days: '-1',
months: 'january, july',
});
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
const requests = await capture;
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
// Check that the last mute_time_interval is the one we just submitted via the form
expect(alertmanagerUpdate.alertmanager_config.mute_time_intervals.pop().name).toEqual('maintenance period');
});
it('creates a new mute timing, with time_intervals in config', async () => {
mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
return Promise.resolve({
...defaultConfigWithNewTimeIntervalsField,
});
const capture = captureRequests();
setAlertmanagerConfig(defaultConfigWithNewTimeIntervalsField);
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/new',
search: `?alertmanager=${alertmanagerName}`,
});
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
await userEvent.type(ui.nameField.get(), 'maintenance period');
await userEvent.type(ui.startsAt.get(), '22:00');
await userEvent.type(ui.endsAt.get(), '24:00');
await userEvent.type(ui.days.get(), '-1');
await userEvent.type(ui.months.get(), 'january, july');
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
{
name: 'maintenance period',
time_intervals: [
{
days_of_month: ['-1'],
months: ['january', 'july'],
times: [
{
start_time: '22:00',
end_time: '24:00',
},
],
},
],
},
],
},
await fillOutForm({
name: 'maintenance period',
startsAt: '22:01',
endsAt: '24:00',
days: '-1',
months: 'january, july',
});
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
const requests = await capture;
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
expect(alertmanagerUpdate.alertmanager_config.time_intervals.pop().name).toEqual('maintenance period');
});
it('creates a new mute timing, with time_intervals and mute_time_intervals in config', async () => {
mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
return Promise.resolve({
...defaultConfigWithBothTimeIntervalsField,
});
});
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
it('creates a new mute timing, with time_intervals and mute_time_intervals in config', async () => {
setGrafanaAlertmanagerConfig(defaultConfigWithBothTimeIntervalsField);
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/new',
search: `?alertmanager=${alertmanagerName}`,
});
expect(ui.nameField.get()).toBeInTheDocument();
await userEvent.type(ui.nameField.get(), 'maintenance period');
await userEvent.type(ui.startsAt.get(), '22:00');
await userEvent.type(ui.endsAt.get(), '24:00');
await userEvent.type(ui.days.get(), '-1');
await userEvent.type(ui.months.get(), 'january, july');
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals, time_intervals, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
muteTimeInterval2,
{
name: 'maintenance period',
time_intervals: [
{
days_of_month: ['-1'],
months: ['january', 'july'],
times: [
{
start_time: '22:00',
end_time: '24:00',
},
],
},
],
},
],
},
await fillOutForm({
name: 'maintenance period',
startsAt: '22:00',
endsAt: '24:00',
days: '-1',
months: 'january, july',
});
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
});
it('prepopulates the form when editing a mute timing', async () => {
renderMuteTimings('/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`);
const capture = captureRequests();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?muteName=${encodeURIComponent(muteTimeInterval.name)}`,
});
expect(await ui.nameField.find()).toBeInTheDocument();
expect(ui.nameField.get()).toHaveValue(muteTimeInterval.name);
expect(ui.months.get()).toHaveValue(muteTimeInterval.time_intervals[0].months?.join(', '));
@ -317,121 +296,109 @@ describe('Mute timings', () => {
const monday = within(ui.weekdays.get()).getByText('Mon');
await userEvent.click(monday);
await userEvent.type(ui.days.get(), '-7:-1');
await userEvent.type(ui.months.get(), '3, 6, 9, 12');
await userEvent.type(ui.years.get(), '2021:2024');
fireEvent.submit(ui.form.get());
const formValues = {
days: '-7:-1',
months: '3, 6, 9, 12',
years: '2021:2024',
};
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
alertmanager_config: {
receivers: [
{
name: 'default',
},
{
name: 'critical',
},
],
route: {
receiver: 'default',
group_by: ['alertname'],
routes: [
{
matchers: ['env=prod', 'region!=EU'],
mute_time_intervals: ['default-mute'],
},
],
},
templates: [],
mute_time_intervals: [
{
name: 'default-mute',
time_intervals: [
{
weekdays: ['monday'],
days_of_month: ['-7:-1'],
months: ['3', '6', '9', '12'],
years: ['2021:2024'],
},
],
},
],
},
template_files: {},
await fillOutForm(formValues);
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
const requests = await capture;
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
const mostRecentInterval = alertmanagerUpdate.alertmanager_config.mute_time_intervals.pop().time_intervals[0];
expect(mostRecentInterval).toMatchObject({
days_of_month: [formValues.days],
months: formValues.months.split(', '),
years: [formValues.years],
});
});
it('form is invalid with duplicate mute timing name', async () => {
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
await waitFor(() => expect(ui.nameField.get()).toBeInTheDocument());
await userEvent.type(ui.nameField.get(), 'default-mute');
await userEvent.type(ui.days.get(), '1');
await waitFor(() => expect(ui.nameField.get()).toHaveValue('default-mute'));
await fillOutForm({ name: muteTimeInterval.name, days: '1' });
fireEvent.submit(ui.form.get());
await saveMuteTiming();
// Form state should be invalid and prevent firing of update action
await waitFor(() => expect(byRole('alert').get()).toBeInTheDocument());
expect(mocks.api.updateAlertManagerConfig).not.toHaveBeenCalled();
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
it('replaces mute timings in routes when the mute timing name is changed', async () => {
renderMuteTimings('/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`);
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?muteName=${encodeURIComponent(muteTimeInterval.name)}`,
});
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
expect(await ui.nameField.find()).toBeInTheDocument();
expect(ui.nameField.get()).toHaveValue(muteTimeInterval.name);
await userEvent.clear(ui.nameField.get());
await userEvent.type(ui.nameField.get(), 'Lunch breaks');
await fillOutForm({ name: 'Lunch breaks' });
await saveMuteTiming();
fireEvent.submit(ui.form.get());
await expectedToHaveRedirectedToRoutesRoute();
});
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
alertmanager_config: {
receivers: [
{
name: 'default',
},
{
name: 'critical',
},
],
route: {
receiver: 'default',
group_by: ['alertname'],
routes: [
{
matchers: ['env=prod', 'region!=EU'],
mute_time_intervals: ['Lunch breaks'],
},
],
},
templates: [],
mute_time_intervals: [
{
name: 'Lunch breaks',
time_intervals: [
{
times: [
{
start_time: '12:00',
end_time: '24:00',
},
],
days_of_month: ['15', '-1'],
months: ['august:december', 'march'],
},
],
},
],
},
template_files: {},
it('shows error when mute timing does not exist', async () => {
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?alertmanager=${GRAFANA_RULES_SOURCE_NAME}&muteName=${'does not exist'}`,
});
expect(await screen.findByText(/No matching mute timing found/i)).toBeInTheDocument();
});
describe('alertingApiServer feature toggle', () => {
beforeEach(() => {
config.featureToggles.alertingApiServer = true;
});
it('allows creation of new mute timings', async () => {
await renderMuteTimings({
pathname: '/alerting/routes/mute-timing/new',
});
await fillOutForm({ name: 'a new mute timing' });
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
});
it('shows error when mute timing does not exist', async () => {
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?alertmanager=${GRAFANA_RULES_SOURCE_NAME}&muteName=${TIME_INTERVAL_UID_HAPPY_PATH + '_force_breakage'}`,
});
expect(await screen.findByText(/No matching mute timing found/i)).toBeInTheDocument();
});
it('loads edit form correctly and allows saving', async () => {
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?alertmanager=${GRAFANA_RULES_SOURCE_NAME}&muteName=${TIME_INTERVAL_UID_HAPPY_PATH}`,
});
// For now, we expect the name field to be disabled editing via the k8s API
expect(await ui.nameField.find()).toBeDisabled();
await saveMuteTiming();
await expectedToHaveRedirectedToRoutesRoute();
});
it('loads view form for provisioned interval', async () => {
renderMuteTimings({
pathname: '/alerting/routes/mute-timing/edit',
search: `?muteName=${TIME_INTERVAL_UID_FILE_PROVISIONED}`,
});
expect(await screen.findByText(/This mute timing cannot be edited through the UI/i)).toBeInTheDocument();
});
});
});

View File

@ -1,80 +1,53 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
import { NavModelItem } from '@grafana/data';
import { Alert } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useGetMuteTiming } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { useURLSearchParams } from 'app/features/alerting/unified/hooks/useURLSearchParams';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import MuteTimingForm from './components/mute-timings/MuteTimingForm';
import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig';
import { useAlertmanager } from './state/AlertmanagerContext';
const MuteTimings = () => {
const [queryParams] = useQueryParams();
const EditTimingRoute = () => {
const [queryParams] = useURLSearchParams();
const { selectedAlertmanager } = useAlertmanager();
const { currentData, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager, {
refetchOnFocus: true,
refetchOnReconnect: true,
const name = queryParams.get('muteName')!;
const {
isLoading,
data: timeInterval,
isError,
} = useGetMuteTiming({
alertmanager: selectedAlertmanager!,
name,
});
const config = currentData?.alertmanager_config;
const getMuteTimingByName = useCallback(
(id: string, fromTimeIntervals: boolean): MuteTimeInterval | undefined => {
const time_intervals = fromTimeIntervals ? (config?.time_intervals ?? []) : (config?.mute_time_intervals ?? []);
const timing = time_intervals.find(({ name }: MuteTimeInterval) => name === id);
if (timing) {
const provenance = config?.muteTimeProvenances?.[timing.name];
return {
...timing,
provenance,
};
}
return timing;
},
[config]
);
if (!name) {
return <Redirect to="/alerting/routes" />;
}
return (
<>
{error && !isLoading && !currentData && (
<Alert severity="error" title={`Error loading Alertmanager config for ${selectedAlertmanager}`}>
{error.message || 'Unknown error.'}
</Alert>
)}
{currentData && !error && (
<Switch>
<Route exact path="/alerting/routes/mute-timing/new">
<MuteTimingForm loading={isLoading} />
</Route>
<Route exact path="/alerting/routes/mute-timing/edit">
{() => {
if (queryParams['muteName']) {
const muteTimingInMuteTimings = getMuteTimingByName(String(queryParams['muteName']), false);
const muteTimingInTimeIntervals = getMuteTimingByName(String(queryParams['muteName']), true);
const inTimeIntervals = Boolean(muteTimingInTimeIntervals);
const muteTiming = inTimeIntervals ? muteTimingInTimeIntervals : muteTimingInMuteTimings;
const provenance = muteTiming?.provenance;
<MuteTimingForm
editMode
loading={isLoading}
showError={isError}
muteTiming={timeInterval}
provisioned={timeInterval?.provisioned}
/>
);
};
return (
<MuteTimingForm
loading={isLoading}
fromLegacyTimeInterval={muteTimingInMuteTimings}
fromTimeIntervals={muteTimingInTimeIntervals}
showError={!muteTiming && !isLoading}
provenance={provenance}
/>
);
}
return <Redirect to="/alerting/routes" />;
}}
</Route>
</Switch>
)}
const MuteTimings = () => {
return (
<>
<Switch>
<Route exact path="/alerting/routes/mute-timing/new">
<MuteTimingForm />
</Route>
<Route exact path="/alerting/routes/mute-timing/edit">
<EditTimingRoute />
</Route>
</Switch>
</>
);
};

View File

@ -5,6 +5,7 @@ import { useAsyncFn } from 'react-use';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { Alert, LoadingPlaceholder, Stack, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
@ -15,7 +16,6 @@ import { useGetContactPointsState } from './api/receiversApi';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
import { mergeTimeIntervals } from './components/mute-timings/util';
import {
NotificationPoliciesFilter,
findRoutesByMatchers,
@ -65,6 +65,7 @@ const AmRoutes = () => {
const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager();
const { getRouteGroupsMap } = useRouteGroupsMatcher();
const { data: muteTimings = [] } = useMuteTimings({ alertmanager: selectedAlertmanager ?? '' });
const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? '');
@ -207,9 +208,8 @@ const AmRoutes = () => {
if (!selectedAlertmanager) {
return null;
}
const time_intervals = result?.alertmanager_config ? mergeTimeIntervals(result?.alertmanager_config) : [];
const numberOfMuteTimings = time_intervals.length;
const numberOfMuteTimings = muteTimings.length;
const haveData = result && !resultError && !resultLoading;
const isFetching = !result && resultLoading;
const haveError = resultError && !resultLoading;

View File

@ -23,6 +23,13 @@ export type ExtendedBackendSrvRequest = BackendSrvRequest & {
* will not be shown
*/
errorMessage?: string;
/**
* Data to send with a request. Maps to the `data` property on a `BackendSrvRequest`
*
* This is done to allow us to more easily consume code-gen APIs that expect/send a `body` property
* to endpoints.
*/
body?: BackendSrvRequest['data'];
};
// utility type for passing request options to endpoints
@ -43,10 +50,11 @@ export function withRequestOptions(
export const backendSrvBaseQuery =
(): BaseQueryFn<ExtendedBackendSrvRequest> =>
async ({ successMessage, errorMessage, ...requestOptions }) => {
async ({ successMessage, errorMessage, body, ...requestOptions }) => {
try {
const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions,
...(body && { data: body }),
...(successMessage && { showSuccessAlert: false }),
...(errorMessage && { showErrorAlert: false }),
};

View File

@ -1,5 +1,5 @@
// Overriding the response types when enhancing endpoints is currently fiddly.
// The below approach is taken from/related to the below:
// A potential approach could be taken from the below:
// https://github.com/reduxjs/redux-toolkit/issues/3901#issuecomment-1820995408
// https://github.com/reduxjs/redux-toolkit/issues/3443#issue-1709588268
//
@ -7,26 +7,6 @@
// which may help alleviate this when it lands:
// https://github.com/reduxjs/redux-toolkit/pull/3485
import { DefinitionsFromApi, OverrideResultType } from '@reduxjs/toolkit/query';
import { generatedTimeIntervalsApi } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen';
import {
ListTimeIntervalForAllNamespacesApiResponse,
generatedTimeIntervalsApi,
} from '../openapi/timeIntervalsApi.gen';
type Definitions = DefinitionsFromApi<typeof generatedTimeIntervalsApi>;
type UpdatedDefinitions = Omit<Definitions, 'listTimeIntervalForAllNamespaces'> & {
listTimeIntervalForAllNamespaces: OverrideResultType<
Definitions['listTimeIntervalForAllNamespaces'],
Array<ListTimeIntervalForAllNamespacesApiResponse['items'][0]['spec']>
>;
};
export const timeIntervalsApi = generatedTimeIntervalsApi.enhanceEndpoints<never, UpdatedDefinitions>({
endpoints: {
listTimeIntervalForAllNamespaces: {
transformResponse: (response: ListTimeIntervalForAllNamespacesApiResponse) =>
response.items.map((item) => item.spec),
},
},
});
export const timeIntervalsApi = generatedTimeIntervalsApi;

View File

@ -15,7 +15,9 @@ interface DynamicTablePagination {
export interface DynamicTableColumnProps<T = unknown> {
id: string | number;
/** Column header to display */
label: string;
alignColumn?: 'end' | string;
renderCell: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
size?: number | string;
@ -106,9 +108,9 @@ export const DynamicTable = <T extends object>({
<div className={styles.container} data-testid={dataTestId ?? 'dynamic-table'}>
<div className={styles.row} data-testid="header">
{renderPrefixHeader && renderPrefixHeader()}
{isExpandable && <div className={styles.cell} />}
{isExpandable && <div className={styles.cell()} />}
{cols.map((col) => (
<div className={styles.cell} key={col.id}>
<div className={styles.cell(col.alignColumn)} key={col.id}>
{col.label}
</div>
))}
@ -124,7 +126,7 @@ export const DynamicTable = <T extends object>({
>
{renderPrefixCell && renderPrefixCell(item, index, items)}
{isExpandable && (
<div className={cx(styles.cell, styles.expandCell)}>
<div className={cx(styles.cell(), styles.expandCell)}>
<IconButton
tooltip={`${isItemExpanded ? 'Collapse' : 'Expand'} row`}
data-testid={selectors.components.AlertRules.toggle}
@ -135,7 +137,7 @@ export const DynamicTable = <T extends object>({
)}
{cols.map((col) => (
<div
className={cx(styles.cell, styles.bodyCell, col.className)}
className={cx(styles.cell(col.alignColumn), styles.bodyCell, col.className)}
data-column={col.label}
key={`${item.id}-${col.id}`}
>
@ -230,16 +232,18 @@ const getStyles = <T extends unknown>(
display: 'flex',
padding: theme.spacing(1),
}),
cell: css({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1),
cell: (alignColumn?: string) =>
css({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1),
justifyContent: alignColumn || 'initial',
[theme.breakpoints.down('sm')]: {
padding: `${theme.spacing(1)} 0`,
gridTemplateColumns: '1fr',
},
}),
[theme.breakpoints.down('sm')]: {
padding: `${theme.spacing(1)} 0`,
gridTemplateColumns: '1fr',
},
}),
bodyCell: css({
overflow: 'hidden',

View File

@ -1,6 +1,7 @@
import { ComponentPropsWithoutRef } from 'react';
import { Alert, Badge } from '@grafana/ui';
import { Alert, Badge, Tooltip } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
export enum ProvisionedResource {
ContactPoint = 'contact point',
@ -26,6 +27,37 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps)
);
};
export const ProvisioningBadge = () => {
return <Badge text={'Provisioned'} color={'purple'} />;
export const ProvisioningBadge = ({
tooltip,
provenance,
}: {
tooltip?: boolean;
/**
* If provided, will be used within any displayed tooltip to indicate the type of provisioning
*/
provenance?: string;
}) => {
const badge = <Badge text="Provisioned" color="purple" />;
if (tooltip) {
const provenanceTooltip = (
<Trans i18nKey="alerting.provisioning.badge-tooltip-provenance" provenance={provenance}>
This resource has been provisioned via {{ provenance }} and cannot be edited through the UI
</Trans>
);
const standardTooltip = (
<Trans i18nKey="alerting.provisioning.badge-tooltip-standard">
This resource has been provisioned and cannot be edited through the UI
</Trans>
);
const tooltipContent = provenance ? provenanceTooltip : standardTooltip;
return (
<Tooltip content={tooltipContent}>
<span>{badge}</span>
</Tooltip>
);
}
return badge;
};

View File

@ -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}
</>
);
};

View File

@ -1,18 +1,20 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Alert, Button, Field, FieldSet, Input, LinkButton, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { Trans } from 'app/core/internationalization';
import {
MuteTiming,
useCreateMuteTiming,
useUpdateMuteTiming,
useValidateMuteTiming,
} from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { shouldUseK8sApi } from 'app/features/alerting/unified/components/mute-timings/util';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { updateAlertManagerConfigAction } from '../../state/actions';
import { MuteTimingFields } from '../../types/mute-timing-form';
import { renameMuteTimings } from '../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { createMuteTiming, defaultTimeInterval, isTimeIntervalDisabled } from '../../utils/mute-timings';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
@ -20,14 +22,16 @@ import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { MuteTimingTimeInterval } from './MuteTimingTimeInterval';
interface Props {
fromLegacyTimeInterval?: MuteTimeInterval; // mute time interval when comes from the old config , mute_time_intervals
fromTimeIntervals?: MuteTimeInterval; // mute time interval when comes from the new config , time_intervals. These two fields are mutually exclusive
muteTiming?: MuteTiming;
showError?: boolean;
provenance?: string;
loading?: boolean;
/** Is the current mute timing provisioned? If so, will disable editing via UI */
provisioned?: boolean;
/** Are we editing an existing time interval? */
editMode?: boolean;
}
const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => {
const useDefaultValues = (muteTiming?: MuteTiming): MuteTimingFields => {
const defaultValues = {
name: '',
time_intervals: [defaultTimeInterval],
@ -53,173 +57,99 @@ const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => {
};
};
const replaceMuteTiming = (
originalTimings: MuteTimeInterval[],
existingTiming: MuteTimeInterval | undefined,
newTiming: MuteTimeInterval,
addNew: boolean
) => {
// we only add new timing if addNew is true. Otherwise, we just remove the existing timing
const originalTimingsWithoutNew = existingTiming
? originalTimings?.filter(({ name }) => name !== existingTiming.name)
: originalTimings;
return addNew ? [...originalTimingsWithoutNew, newTiming] : [...originalTimingsWithoutNew];
};
const MuteTimingForm = ({
fromLegacyTimeInterval: fromMuteTimings,
fromTimeIntervals,
showError,
loading,
provenance,
}: Props) => {
const dispatch = useDispatch();
const MuteTimingForm = ({ muteTiming, showError, loading, provisioned, editMode }: Props) => {
const { selectedAlertmanager } = useAlertmanager();
const hookArgs = { alertmanager: selectedAlertmanager! };
const createTimeInterval = useCreateMuteTiming(hookArgs);
const updateTimeInterval = useUpdateMuteTiming(hookArgs);
const validateMuteTiming = useValidateMuteTiming(hookArgs);
/**
* The k8s API approach does not support renaming an entity at this time,
* as it requires renaming all other references of this entity.
*
* For now, the cleanest solution is to disabled renaming the field in this scenario
*/
const disableNameField = editMode && shouldUseK8sApi(selectedAlertmanager!);
const styles = useStyles2(getStyles);
const [updating, setUpdating] = useState(false);
const { currentData: result } = useAlertmanagerConfig(selectedAlertmanager);
const config = result?.alertmanager_config;
const fromIntervals = Boolean(fromTimeIntervals);
const muteTiming = fromIntervals ? fromTimeIntervals : fromMuteTimings;
const originalMuteTimings = config?.mute_time_intervals ?? [];
const originalTimeIntervals = config?.time_intervals ?? [];
const defaultValues = useDefaultValues(muteTiming);
const formApi = useForm({ defaultValues });
const onSubmit = (values: MuteTimingFields) => {
if (!result) {
return;
}
const formApi = useForm({ defaultValues, values: defaultValues });
const updating = formApi.formState.isSubmitting;
const newMuteTiming = createMuteTiming(values);
const returnLink = makeAMLink('/alerting/routes/', selectedAlertmanager!, { tab: 'mute_timings' });
const isGrafanaDataSource = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const isNewMuteTiming = fromTimeIntervals === undefined && fromMuteTimings === undefined;
const onSubmit = async (values: MuteTimingFields) => {
const timeInterval = createMuteTiming(values);
// If is Grafana data source, we wil save mute timings in the alertmanager_config.mute_time_intervals
// Otherwise, we will save it on alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config
const newMutetimeIntervals = isGrafanaDataSource
? {
// for Grafana data source, we will save mute timings in the alertmanager_config.mute_time_intervals
mute_time_intervals: [
...replaceMuteTiming(originalTimeIntervals, fromTimeIntervals, newMuteTiming, false),
...replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true),
],
}
: {
// for non-Grafana data source, we will save mute timings in the alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config
time_intervals: replaceMuteTiming(
originalTimeIntervals,
fromTimeIntervals,
newMuteTiming,
Boolean(fromTimeIntervals) || isNewMuteTiming
),
mute_time_intervals:
Boolean(fromMuteTimings) && !isNewMuteTiming
? replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true)
: undefined,
};
const { mute_time_intervals: _, time_intervals: __, ...configWithoutMuteTimings } = config ?? {};
const newConfig: AlertManagerCortexConfig = {
...result,
alertmanager_config: {
...configWithoutMuteTimings,
route:
muteTiming && newMuteTiming.name !== muteTiming.name
? renameMuteTimings(newMuteTiming.name, muteTiming.name, config?.route ?? {})
: config?.route,
...newMutetimeIntervals,
},
const updateOrCreate = async () => {
if (editMode) {
return updateTimeInterval({ timeInterval, originalName: muteTiming?.metadata?.name || muteTiming!.name });
}
return createTimeInterval({ timeInterval });
};
const saveAction = dispatch(
updateAlertManagerConfigAction({
newConfig,
oldConfig: result,
alertManagerSourceName: selectedAlertmanager!,
successMessage: 'Mute timing saved',
redirectPath: '/alerting/routes/',
redirectSearch: 'tab=mute_timings',
})
);
setUpdating(true);
saveAction.unwrap().finally(() => {
setUpdating(false);
return updateOrCreate().then(() => {
locationService.push(returnLink);
});
};
if (loading) {
return <LoadingPlaceholder text="Loading mute timing" />;
}
if (showError) {
return <Alert title="No matching mute timing found" />;
}
return (
<>
{provenance && <ProvisioningAlert resource={ProvisionedResource.MuteTiming} />}
{loading && <LoadingPlaceholder text="Loading mute timing" />}
{showError && <Alert title="No matching mute timing found" />}
{result && !loading && !showError && (
<FormProvider {...formApi}>
<form onSubmit={formApi.handleSubmit(onSubmit)} data-testid="mute-timing-form">
<FieldSet label={'Create mute timing'} disabled={Boolean(provenance) || updating}>
<Field
required
label="Name"
description="A unique name for the mute timing"
invalid={!!formApi.formState.errors?.name}
error={formApi.formState.errors.name?.message}
>
<Input
{...formApi.register('name', {
required: true,
validate: (value) =>
validateMuteTiming(value, muteTiming, originalMuteTimings, originalTimeIntervals),
})}
className={styles.input}
data-testid={'mute-timing-name'}
/>
</Field>
<MuteTimingTimeInterval />
<Button type="submit" className={styles.submitButton} disabled={updating}>
Save mute timing
</Button>
<LinkButton
type="button"
variant="secondary"
fill="outline"
href={makeAMLink('/alerting/routes/', selectedAlertmanager, { tab: 'mute_timings' })}
disabled={updating}
>
Cancel
</LinkButton>
</FieldSet>
</form>
</FormProvider>
)}
{provisioned && <ProvisioningAlert resource={ProvisionedResource.MuteTiming} />}
<FormProvider {...formApi}>
<form onSubmit={formApi.handleSubmit(onSubmit)} data-testid="mute-timing-form">
<FieldSet label={'Create mute timing'} disabled={provisioned || updating}>
<Field
required
label="Name"
description="A unique name for the mute timing"
invalid={!!formApi.formState.errors?.name}
error={formApi.formState.errors.name?.message}
disabled={disableNameField}
>
<Input
{...formApi.register('name', {
required: true,
validate: async (value) => {
const skipValidation = editMode && value === muteTiming?.name;
return validateMuteTiming(value, skipValidation);
},
})}
className={styles.input}
data-testid={'mute-timing-name'}
/>
</Field>
<MuteTimingTimeInterval />
<Button
type="submit"
className={styles.submitButton}
disabled={updating}
icon={updating ? 'spinner' : undefined}
>
{updating ? (
<Trans i18nKey="alerting.mute-timings.saving">Saving mute timing</Trans>
) : (
<Trans i18nKey="alerting.mute-timings.save">Save mute timing</Trans>
)}
</Button>
<LinkButton type="button" variant="secondary" fill="outline" href={returnLink} disabled={updating}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</LinkButton>
</FieldSet>
</form>
</FormProvider>
</>
);
};
function validateMuteTiming(
value: string,
muteTiming: MuteTimeInterval | undefined,
originalMuteTimings: MuteTimeInterval[],
originalTimeIntervals: MuteTimeInterval[]
) {
if (!muteTiming) {
const existingMuteTimingInMuteTimings = originalMuteTimings?.find(({ name }) => value === name);
const existingMuteTimingInTimeIntervals = originalTimeIntervals?.find(({ name }) => value === name);
return existingMuteTimingInMuteTimings || existingMuteTimingInTimeIntervals
? `Mute timing already exists for "${value}"`
: true;
}
return;
}
const getStyles = (theme: GrafanaTheme2) => ({
input: css({
width: '400px',

View File

@ -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();
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 allowed ', async () => {
// when not allowed
grantUserPermissions([]);
renderWithProvider();
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();
});
});
it('It does not show export button when not supported ', async () => {
// when not supported
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
renderWithProvider('potato');
await waitFor(() => {
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();
});
});
});

View File

@ -1,104 +1,73 @@
import { css } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, 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';
import { Alert, Button, LinkButton, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { MuteTimingActionsButtons } from 'app/features/alerting/unified/components/mute-timings/MuteTimingActionsButtons';
import {
ALL_MUTE_TIMINGS,
useExportMuteTimingsDrawer,
} from 'app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { deleteMuteTimingAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { isDisabled } from '../../utils/mute-timings';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { DynamicTable, DynamicTableColumnProps } from '../DynamicTable';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter';
import { mergeTimeIntervals, renderTimeIntervals } from './util';
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];
};
import { MuteTiming, PROVENANCE_ANNOTATION, useMuteTimings } from './useMuteTimings';
import { renderTimeIntervals } from './util';
interface MuteTimingsTableProps {
alertManagerSourceName: string;
muteTimingNames?: string[];
hideActions?: boolean;
}
export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hideActions }: MuteTimingsTableProps) => {
type TableItem = {
id: string;
data: MuteTiming;
};
export const MuteTimingsTable = ({ alertManagerSourceName, hideActions }: MuteTimingsTableProps) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [ExportAllDrawer, showExportAllDrawer] = useExportMuteTimingsDrawer();
const { currentData } = useAlertmanagerConfig(alertManagerSourceName, {
refetchOnFocus: true,
refetchOnReconnect: true,
});
const config = currentData?.alertmanager_config;
const { data, isLoading, error } = useMuteTimings({ alertmanager: alertManagerSourceName });
const [muteTimingName, setMuteTimingName] = useState<string>('');
const items = useMemo((): Array<DynamicTableItemProps<MuteTimeInterval>> => {
// merge both fields mute_time_intervals and time_intervals to support both old and new config
const muteTimings = config ? mergeTimeIntervals(config) : [];
const muteTimingsProvenances = config?.muteTimeProvenances ?? {};
const items = useMemo((): TableItem[] => {
const muteTimings = data || [];
return muteTimings
.filter(({ name }) => (muteTimingNames ? muteTimingNames.includes(name) : true))
.map((mute) => {
return {
id: mute.name,
data: {
...mute,
provenance: muteTimingsProvenances[mute.name],
},
};
});
}, [muteTimingNames, config]);
return muteTimings.map((mute) => {
return {
id: mute.id,
data: mute,
};
});
}, [data]);
const [_, allowedToCreateMuteTiming] = useAlertmanagerAbility(AlertmanagerAction.CreateMuteTiming);
const [ExportDrawer, showExportDrawer] = useExportMuteTiming();
const [exportMuteTimingsSupported, exportMuteTimingsAllowed] = useAlertmanagerAbility(
AlertmanagerAction.ExportMuteTimings
);
const columns = useColumns(alertManagerSourceName, hideActions, setMuteTimingName, showExportDrawer);
const columns = useColumns(alertManagerSourceName, hideActions);
if (isLoading) {
return <LoadingPlaceholder text="Loading mute timings..." />;
}
if (error) {
return (
<Alert severity="error" title={t('alerting.mute_timings.error-loading.title', 'Error loading mute timings')}>
<Trans i18nKey="alerting.mute_timings.error-loading.description">
Could not load mute timings. Please try again later.
</Trans>
</Alert>
);
}
return (
<div className={styles.container}>
@ -121,16 +90,18 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
</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>
<>
<Button
icon="download-alt"
className={styles.muteTimingsButtons}
variant="secondary"
disabled={!exportMuteTimingsAllowed}
onClick={() => showExportAllDrawer(ALL_MUTE_TIMINGS)}
>
<Trans i18nKey="alerting.common.export-all">Export all</Trans>
</Button>
{ExportAllDrawer}
</>
)}
</Stack>
{items.length > 0 ? (
@ -147,52 +118,33 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
) : (
<EmptyAreaWithCTA text="No mute timings configured" buttonLabel={''} showButton={false} />
)}
{!hideActions && (
<ConfirmModal
isOpen={!!muteTimingName}
title="Delete mute timing"
body={`Are you sure you would like to delete "${muteTimingName}"`}
confirmText="Delete"
onConfirm={() => {
dispatch(deleteMuteTimingAction(alertManagerSourceName, muteTimingName));
setMuteTimingName('');
}}
onDismiss={() => setMuteTimingName('')}
/>
)}
{ExportDrawer}
</div>
);
};
function useColumns(
alertManagerSourceName: string,
hideActions = false,
setMuteTimingName: (name: string) => void,
openExportDrawer: (muteTiming: string | typeof ALL_MUTE_TIMINGS) => void
) {
function useColumns(alertManagerSourceName: string, hideActions = false) {
const [[_editSupported, allowedToEdit], [_deleteSupported, allowedToDelete]] = useAlertmanagerAbilities([
AlertmanagerAction.UpdateMuteTiming,
AlertmanagerAction.DeleteMuteTiming,
]);
const showActions = !hideActions && (allowedToEdit || allowedToDelete);
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportMuteTimings);
const styles = useStyles2(getStyles);
return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => {
const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [
return useMemo((): Array<DynamicTableColumnProps<MuteTiming>> => {
const columns: Array<DynamicTableColumnProps<MuteTiming>> = [
{
id: 'name',
label: 'Name',
renderCell: function renderName({ data }) {
return (
<>
{data.name} {data.provenance && <ProvisioningBadge />}
</>
<div>
{data.name}{' '}
{data.provisioned && (
<ProvisioningBadge tooltip provenance={data.metadata?.annotations?.[PROVENANCE_ANNOTATION]} />
)}
</div>
);
},
size: '250px',
size: 1,
},
{
id: 'timeRange',
@ -200,103 +152,22 @@ function useColumns(
renderCell: ({ data }) => {
return renderTimeIntervals(data);
},
size: 5,
},
];
if (showActions) {
columns.push({
id: 'actions',
label: '',
renderCell: function renderActions({ data }) {
return (
<ActionsAndBadge
muteTiming={data}
alertManagerSourceName={alertManagerSourceName}
setMuteTimingName={setMuteTimingName}
/>
);
},
size: '150px',
className: styles.actionsColumn,
});
}
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',
label: 'Actions',
alignColumn: 'end',
renderCell: ({ data }) => (
<MuteTimingActionsButtons muteTiming={data} alertManagerSourceName={alertManagerSourceName} />
),
size: 2,
});
}
return columns;
}, [
alertManagerSourceName,
setMuteTimingName,
showActions,
exportSupported,
exportAllowed,
openExportDrawer,
styles.actionsColumn,
]);
}
interface ActionsAndBadgeProps {
muteTiming: MuteTimeInterval;
alertManagerSourceName: string;
setMuteTimingName: (name: string) => void;
}
function ActionsAndBadge({ muteTiming, alertManagerSourceName, setMuteTimingName }: ActionsAndBadgeProps) {
const styles = useStyles2(getStyles);
const isGrafanaDataSource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
if (muteTiming.provenance) {
return (
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{isDisabled(muteTiming) && !isGrafanaDataSource && (
<Badge text="Disabled" color="orange" className={styles.disabledBadge} />
)}
<Link
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, {
muteName: muteTiming.name,
})}
>
<IconButton name="file-alt" tooltip="View mute timing" />
</Link>
</Stack>
);
}
return (
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{isDisabled(muteTiming) && !isGrafanaDataSource && (
<Badge text="Disabled" color="orange" className={styles.disabledBadge} />
)}
<Authorize actions={[AlertmanagerAction.UpdateMuteTiming]}>
<Link
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, {
muteName: muteTiming.name,
})}
>
<IconButton name="edit" tooltip="Edit mute timing" className={styles.editButton} />
</Link>
</Authorize>
<Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}>
<IconButton name="trash-alt" tooltip="Delete mute timing" onClick={() => setMuteTimingName(muteTiming.name)} />
</Authorize>
</Stack>
);
}, [showActions, alertManagerSourceName]);
}
const getStyles = (theme: GrafanaTheme2) => ({
@ -306,15 +177,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
muteTimingsButtons: css({
marginBottom: theme.spacing(2),
alignSelf: 'flex-end',
}),
disabledBadge: css({
height: 'fit-content',
}),
editButton: css({
display: 'flex',
}),
actionsColumn: css({
justifyContent: 'flex-end',
}),
});

View File

@ -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>
`;

View File

@ -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];
};

View File

@ -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;
});
};
};

View File

@ -1,6 +1,9 @@
import moment from 'moment';
import { Fragment } from 'react';
import { config } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import {
@ -14,17 +17,18 @@ import {
// https://github.com/prometheus/alertmanager/blob/9de8ef36755298a68b6ab20244d4369d38bdea99/timeinterval/timeinterval.go#L443
const TIME_RANGE_REGEX = /^((([01][0-9])|(2[0-3])):[0-5][0-9])$|(^24:00$)/;
const isvalidTimeFormat = (timeString: string): boolean => {
export const isvalidTimeFormat = (timeString: string): boolean => {
return timeString ? TIME_RANGE_REGEX.test(timeString) : true;
};
// merge both fields mute_time_intervals and time_intervals to support both old and new config
/**
* Merges `mute_time_intervals` and `time_intervals` from alertmanager config to support both old and new config
*/
export const mergeTimeIntervals = (alertManagerConfig: AlertmanagerConfig) => {
return [...(alertManagerConfig.mute_time_intervals ?? []), ...(alertManagerConfig.time_intervals ?? [])];
};
// Usage
const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean => {
export const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean => {
// empty time range is perfactly valid for a mute timing
if (!startTime && !endTime) {
return true;
@ -51,10 +55,10 @@ const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean =
return false;
};
function renderTimeIntervals(muteTiming: MuteTimeInterval) {
export function renderTimeIntervals(muteTiming: MuteTimeInterval) {
const timeIntervals = muteTiming.time_intervals;
return timeIntervals.map((interval, index) => {
const intervals = timeIntervals.map((interval, index) => {
const { times, weekdays, days_of_month, months, years, location } = interval;
const timeString = getTimeString(times, location);
const weekdayString = getWeekdayString(weekdays);
@ -64,13 +68,35 @@ function renderTimeIntervals(muteTiming: MuteTimeInterval) {
return (
<Fragment key={JSON.stringify(interval) + index}>
{`${timeString} ${weekdayString}`}
<br />
{[daysString, monthsString, yearsString].join(' | ')}
<br />
<div>
{`${timeString} ${weekdayString}`}
<br />
{[daysString, monthsString, yearsString].join(' | ')}
<br />
</div>
</Fragment>
);
});
return (
<Stack direction="column" gap={1}>
{intervals}
</Stack>
);
}
export { isValidStartAndEndTime, isvalidTimeFormat, renderTimeIntervals };
/**
* Get the correct namespace to use when using the K8S API.
*/
export const getK8sNamespace = () => config.namespace;
/**
* Should we call the kubernetes-style API for managing the time intervals?
*
* Requires the alertmanager referenced being the Grafana AM,
* and the `alertingApiServer` feature toggle being enabled
*/
export const shouldUseK8sApi = (alertmanager?: string) => {
const featureToggleEnabled = config.featureToggles.alertingApiServer;
return featureToggleEnabled && alertmanager === GRAFANA_RULES_SOURCE_NAME;
};

View File

@ -12,8 +12,10 @@ import mimirRulerHandlers from 'app/features/alerting/unified/mocks/server/handl
import notificationsHandlers from 'app/features/alerting/unified/mocks/server/handlers/notifications';
import pluginsHandlers from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import allPluginHandlers from 'app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers';
import provisioningHandlers from 'app/features/alerting/unified/mocks/server/handlers/provisioning';
import searchHandlers from 'app/features/alerting/unified/mocks/server/handlers/search';
import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers/silences';
import timeIntervalK8sHandlers from 'app/features/alerting/unified/mocks/server/handlers/timeIntervals.k8s';
/**
* Array of all mock handlers that are required across Alerting tests
@ -27,11 +29,15 @@ const allHandlers = [
...evalHandlers,
...folderHandlers,
...pluginsHandlers,
...provisioningHandlers,
...silenceHandlers,
...searchHandlers,
...notificationsHandlers,
...allPluginHandlers,
// Kubernetes-style handlers
...timeIntervalK8sHandlers,
];
export default allHandlers;

View File

@ -1,9 +1,10 @@
import { HttpResponse } from 'msw';
import { HttpResponse, http } from 'msw';
import { config } from '@grafana/runtime';
import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi';
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
import {
getAlertmanagerConfigHandler,
getGrafanaAlertmanagerConfigHandler,
grafanaAlertingConfigurationStatusHandler,
} from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers';
@ -12,6 +13,7 @@ import {
getDisabledPluginHandler,
getPluginMissingHandler,
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/timeIntervals.k8s';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
import { AlertManagerCortexConfig, AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
@ -54,6 +56,13 @@ export const setGrafanaAlertmanagerConfig = (config: AlertManagerCortexConfig) =
server.use(getGrafanaAlertmanagerConfigHandler(config));
};
/**
* Makes the mock server respond with different (other) Alertmanager config
*/
export const setAlertmanagerConfig = (config: AlertManagerCortexConfig) => {
server.use(getAlertmanagerConfigHandler(config));
};
/**
* Makes the mock server respond with different responses for updating a ruler namespace
*/
@ -65,7 +74,7 @@ export const setUpdateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
};
/**
* Makes the mock server response with different responses for a ruler rule group
* Makes the mock server respond with different responses for a ruler rule group
*/
export const setRulerRuleGroupHandler = (options?: HandlerOptions) => {
const handler = rulerRuleGroupHandler(options);
@ -74,6 +83,19 @@ export const setRulerRuleGroupHandler = (options?: HandlerOptions) => {
return handler;
};
/**
* Makes the mock server respond with an error when fetching list of mute timings
*/
export const setMuteTimingsListError = () => {
const listMuteTimingsPath = listNamespacedTimeIntervalHandler().info.path;
const handler = http.get(listMuteTimingsPath, () => {
return HttpResponse.json({}, { status: 401 });
});
server.use(handler);
return handler;
};
export function mimirDataSource() {
const dataSource = mockDataSource(
{

View File

@ -31,10 +31,52 @@ export const alertmanagerAlertsListHandler = () =>
export const getGrafanaAlertmanagerConfigHandler = (config: AlertManagerCortexConfig = alertmanagerConfigMock) =>
http.get('/api/alertmanager/grafana/config/api/v1/alerts', () => HttpResponse.json(config));
export const getAlertmanagerConfigHandler = (config: AlertManagerCortexConfig = alertmanagerConfigMock) =>
http.get('/api/alertmanager/:name/config/api/v1/alerts', () => HttpResponse.json(config));
const alertmanagerUpdateError = HttpResponse.json({ message: 'bad request' }, { status: 400 });
/** Perform some basic validation on the config that we expect the backend to also do */
const validateGrafanaAlertmanagerConfig = (config: AlertManagerCortexConfig) => {
const { alertmanager_config } = config;
const { route, time_intervals = [], mute_time_intervals = [] } = alertmanager_config;
const intervals = [...time_intervals, ...mute_time_intervals];
const intervalsByName = new Set(intervals.map((interval) => interval.name));
const duplicatedIntervals = intervalsByName.size !== intervals.length;
if (route) {
const routesReferencingMissingMuteTimings = Boolean(
route.routes?.find((route) => {
return route.mute_time_intervals?.some((name) => !intervalsByName.has(name));
})
);
if (routesReferencingMissingMuteTimings) {
return alertmanagerUpdateError;
}
}
if (duplicatedIntervals) {
return alertmanagerUpdateError;
}
return null;
};
const updateGrafanaAlertmanagerConfigHandler = () =>
http.post('/api/alertmanager/grafana/config/api/v1/alerts', () =>
HttpResponse.json({ message: 'configuration created' })
);
http.post('/api/alertmanager/grafana/config/api/v1/alerts', async ({ request }) => {
const body: AlertManagerCortexConfig = await request.clone().json();
const potentialError = validateGrafanaAlertmanagerConfig(body);
return potentialError ? potentialError : HttpResponse.json({ message: 'configuration created' });
});
const updateAlertmanagerConfigHandler = () =>
http.post('/api/alertmanager/:name/config/api/v1/alerts', async ({ request }) => {
const body: AlertManagerCortexConfig = await request.clone().json();
const potentialError = validateGrafanaAlertmanagerConfig(body);
return potentialError ? potentialError : HttpResponse.json({ message: 'configuration created' });
});
const getGrafanaAlertmanagerTemplatePreview = () =>
http.post('/api/alertmanager/grafana/config/api/v1/templates/test', () =>
@ -49,7 +91,9 @@ const handlers = [
alertmanagerAlertsListHandler(),
grafanaAlertingConfigurationStatusHandler(),
getGrafanaAlertmanagerConfigHandler(),
getAlertmanagerConfigHandler(),
updateGrafanaAlertmanagerConfigHandler(),
updateAlertmanagerConfigHandler(),
getGrafanaAlertmanagerTemplatePreview(),
getGrafanaReceiversHandler(),
];

View File

@ -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;

View File

@ -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;

View File

@ -6,19 +6,16 @@ const injectedRtkApi = api
})
.injectEndpoints({
endpoints: (build) => ({
listTimeIntervalForAllNamespaces: build.query<
ListTimeIntervalForAllNamespacesApiResponse,
ListTimeIntervalForAllNamespacesApiArg
>({
listNamespacedTimeInterval: build.query<ListNamespacedTimeIntervalApiResponse, ListNamespacedTimeIntervalApiArg>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/timeintervals`,
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals`,
params: {
pretty: queryArg.pretty,
allowWatchBookmarks: queryArg.allowWatchBookmarks,
continue: queryArg['continue'],
fieldSelector: queryArg.fieldSelector,
labelSelector: queryArg.labelSelector,
limit: queryArg.limit,
pretty: queryArg.pretty,
resourceVersion: queryArg.resourceVersion,
resourceVersionMatch: queryArg.resourceVersionMatch,
sendInitialEvents: queryArg.sendInitialEvents,
@ -28,13 +25,94 @@ const injectedRtkApi = api
}),
providesTags: ['TimeInterval'],
}),
createNamespacedTimeInterval: build.mutation<
CreateNamespacedTimeIntervalApiResponse,
CreateNamespacedTimeIntervalApiArg
>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals`,
method: 'POST',
body: queryArg.comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['TimeInterval'],
}),
readNamespacedTimeInterval: build.query<ReadNamespacedTimeIntervalApiResponse, ReadNamespacedTimeIntervalApiArg>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals/${queryArg.name}`,
params: { pretty: queryArg.pretty },
}),
providesTags: ['TimeInterval'],
}),
replaceNamespacedTimeInterval: build.mutation<
ReplaceNamespacedTimeIntervalApiResponse,
ReplaceNamespacedTimeIntervalApiArg
>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals/${queryArg.name}`,
method: 'PUT',
body: queryArg.comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['TimeInterval'],
}),
deleteNamespacedTimeInterval: build.mutation<
DeleteNamespacedTimeIntervalApiResponse,
DeleteNamespacedTimeIntervalApiArg
>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals/${queryArg.name}`,
method: 'DELETE',
body: queryArg.ioK8SApimachineryPkgApisMetaV1DeleteOptions,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
},
}),
invalidatesTags: ['TimeInterval'],
}),
patchNamespacedTimeInterval: build.mutation<
PatchNamespacedTimeIntervalApiResponse,
PatchNamespacedTimeIntervalApiArg
>({
query: (queryArg) => ({
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals/${queryArg.name}`,
method: 'PATCH',
body: queryArg.ioK8SApimachineryPkgApisMetaV1Patch,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
force: queryArg.force,
},
}),
invalidatesTags: ['TimeInterval'],
}),
}),
overrideExisting: false,
});
export { injectedRtkApi as generatedTimeIntervalsApi };
export type ListTimeIntervalForAllNamespacesApiResponse =
export type ListNamespacedTimeIntervalApiResponse =
/** status 200 OK */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeIntervalList;
export type ListTimeIntervalForAllNamespacesApiArg = {
export type ListNamespacedTimeIntervalApiArg = {
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */
allowWatchBookmarks?: boolean;
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
@ -49,8 +127,6 @@ export type ListTimeIntervalForAllNamespacesApiArg = {
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
limit?: number;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
@ -78,6 +154,91 @@ export type ListTimeIntervalForAllNamespacesApiArg = {
/** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */
watch?: boolean;
};
export type CreateNamespacedTimeIntervalApiResponse = /** status 200 OK */
| ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval
| /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval
| /** status 202 Accepted */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
export type CreateNamespacedTimeIntervalApiArg = {
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
};
export type ReadNamespacedTimeIntervalApiResponse =
/** status 200 OK */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
export type ReadNamespacedTimeIntervalApiArg = {
/** name of the TimeInterval */
name: string;
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
};
export type ReplaceNamespacedTimeIntervalApiResponse = /** status 200 OK */
| ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval
| /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
export type ReplaceNamespacedTimeIntervalApiArg = {
/** name of the TimeInterval */
name: string;
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
};
export type DeleteNamespacedTimeIntervalApiResponse = /** status 200 OK */
| IoK8SApimachineryPkgApisMetaV1Status
| /** status 202 Accepted */ IoK8SApimachineryPkgApisMetaV1Status;
export type DeleteNamespacedTimeIntervalApiArg = {
/** name of the TimeInterval */
name: string;
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
ioK8SApimachineryPkgApisMetaV1DeleteOptions: IoK8SApimachineryPkgApisMetaV1DeleteOptions;
};
export type PatchNamespacedTimeIntervalApiResponse = /** status 200 OK */
| ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval
| /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval;
export type PatchNamespacedTimeIntervalApiArg = {
/** name of the TimeInterval */
name: string;
/** object name and auth scope, such as for teams and projects */
namespace: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
/** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */
force?: boolean;
ioK8SApimachineryPkgApisMetaV1Patch: IoK8SApimachineryPkgApisMetaV1Patch;
};
export type IoK8SApimachineryPkgApisMetaV1Time = string;
export type IoK8SApimachineryPkgApisMetaV1FieldsV1 = object;
export type IoK8SApimachineryPkgApisMetaV1ManagedFieldsEntry = {
@ -202,3 +363,70 @@ export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInter
kind?: string;
metadata: IoK8SApimachineryPkgApisMetaV1ListMeta;
};
export type IoK8SApimachineryPkgApisMetaV1StatusCause = {
/** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.
Examples:
"name" - the field "name" on the current resource
"items[0].name" - the field "name" on the first array entry in "items" */
field?: string;
/** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */
message?: string;
/** A machine-readable description of the cause of the error. If this value is empty there is no information available. */
reason?: string;
};
export type IoK8SApimachineryPkgApisMetaV1StatusDetails = {
/** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */
causes?: IoK8SApimachineryPkgApisMetaV1StatusCause[];
/** The group attribute of the resource associated with the status StatusReason. */
group?: string;
/** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */
name?: string;
/** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */
retryAfterSeconds?: number;
/** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string;
};
export type IoK8SApimachineryPkgApisMetaV1Status = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Suggested HTTP return code for this status, 0 if not set. */
code?: number;
/** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */
details?: IoK8SApimachineryPkgApisMetaV1StatusDetails;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** A human-readable description of the status of this operation. */
message?: string;
/** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
metadata?: IoK8SApimachineryPkgApisMetaV1ListMeta;
/** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */
reason?: string;
/** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */
status?: string;
};
export type IoK8SApimachineryPkgApisMetaV1Preconditions = {
/** Specifies the target ResourceVersion */
resourceVersion?: string;
/** Specifies the target UID. */
uid?: string;
};
export type IoK8SApimachineryPkgApisMetaV1DeleteOptions = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string[];
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned. */
preconditions?: IoK8SApimachineryPkgApisMetaV1Preconditions;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
};
export type IoK8SApimachineryPkgApisMetaV1Patch = object;

View File

@ -612,31 +612,29 @@ export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimin
mute_time_intervals: muteIntervalsFiltered,
};
if (config) {
const { mute_time_intervals: _, ...configWithoutMuteTimings } = config?.alertmanager_config ?? {};
withAppEvents(
dispatch(
updateAlertManagerConfigAction({
alertManagerSourceName,
oldConfig: config,
newConfig: {
...config,
alertmanager_config: {
...configWithoutMuteTimings,
route: config.alertmanager_config.route
? removeMuteTimingFromRoute(muteTimingName, config.alertmanager_config?.route)
: undefined,
...time_intervals_without_mute_to_save,
},
const { mute_time_intervals: _, ...configWithoutMuteTimings } = config?.alertmanager_config ?? {};
return withAppEvents(
dispatch(
updateAlertManagerConfigAction({
alertManagerSourceName,
oldConfig: config,
newConfig: {
...config,
alertmanager_config: {
...configWithoutMuteTimings,
route: config.alertmanager_config.route
? removeMuteTimingFromRoute(muteTimingName, config.alertmanager_config?.route)
: undefined,
...time_intervals_without_mute_to_save,
},
})
),
{
successMessage: `Deleted "${muteTimingName}" from Alertmanager configuration`,
errorMessage: 'Failed to delete mute timing',
}
);
}
},
})
),
{
successMessage: `Deleted "${muteTimingName}" from Alertmanager configuration`,
errorMessage: 'Failed to delete mute timing',
}
);
};
};

View File

@ -330,7 +330,7 @@ export interface TimeInterval {
export type MuteTimeInterval = {
name: string;
time_intervals: TimeInterval[];
provenance?: string;
provisioned?: boolean;
};
export interface AlertManagerDataSourceJsonData extends DataSourceJsonData {

View File

@ -99,6 +99,14 @@
"title": "Unable to display all events"
}
},
"common": {
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"export": "Export",
"export-all": "Export all",
"view": "View"
},
"contact-points": {
"delivery-duration": "Last delivery took <1></1>",
"last-delivery-attempt": "Last delivery attempt",
@ -118,6 +126,16 @@
"new-recording-rule": "New recording rule",
"title": "Grafana"
},
"mute_timings": {
"error-loading": {
"description": "Could not load mute timings. Please try again later.",
"title": "Error loading mute timings"
}
},
"mute-timings": {
"save": "Save mute timing",
"saving": "Saving mute timing"
},
"policies": {
"metadata": {
"timingOptions": {
@ -136,6 +154,10 @@
}
}
},
"provisioning": {
"badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI",
"badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI"
},
"rule-groups": {
"delete": {
"success": "Successfully deleted rule group"

View File

@ -99,6 +99,14 @@
"title": "Ůʼnäþľę ŧő đįşpľäy äľľ ęvęʼnŧş"
}
},
"common": {
"cancel": "Cäʼnčęľ",
"delete": "Đęľęŧę",
"edit": "Ēđįŧ",
"export": "Ēχpőřŧ",
"export-all": "Ēχpőřŧ äľľ",
"view": "Vįęŵ"
},
"contact-points": {
"delivery-duration": "Ŀäşŧ đęľįvęřy ŧőőĸ <1></1>",
"last-delivery-attempt": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ",
@ -118,6 +126,16 @@
"new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę",
"title": "Ğřäƒäʼnä"
},
"mute_timings": {
"error-loading": {
"description": "Cőūľđ ʼnőŧ ľőäđ mūŧę ŧįmįʼnģş. Pľęäşę ŧřy äģäįʼn ľäŧęř.",
"title": "Ēřřőř ľőäđįʼnģ mūŧę ŧįmįʼnģş"
}
},
"mute-timings": {
"save": "Ŝävę mūŧę ŧįmįʼnģ",
"saving": "Ŝävįʼnģ mūŧę ŧįmįʼnģ"
},
"policies": {
"metadata": {
"timingOptions": {
@ -136,6 +154,10 @@
}
}
},
"provisioning": {
"badge-tooltip-provenance": "Ŧĥįş řęşőūřčę ĥäş þęęʼn přővįşįőʼnęđ vįä {{provenance}} äʼnđ čäʼnʼnőŧ þę ęđįŧęđ ŧĥřőūģĥ ŧĥę ŮĨ",
"badge-tooltip-standard": "Ŧĥįş řęşőūřčę ĥäş þęęʼn přővįşįőʼnęđ äʼnđ čäʼnʼnőŧ þę ęđįŧęđ ŧĥřőūģĥ ŧĥę ŮĨ"
},
"rule-groups": {
"delete": {
"success": "Ŝūččęşşƒūľľy đęľęŧęđ řūľę ģřőūp"

View File

@ -28,7 +28,14 @@ const config: ConfigFile = {
'../public/app/features/alerting/unified/openapi/timeIntervalsApi.gen.ts': {
apiFile: '../public/app/features/alerting/unified/api/alertingApi.ts',
apiImport: 'alertingApi',
filterEndpoints: ['listTimeIntervalForAllNamespaces'],
filterEndpoints: [
'listNamespacedTimeInterval',
'createNamespacedTimeInterval',
'readNamespacedTimeInterval',
'deleteNamespacedTimeInterval',
'patchNamespacedTimeInterval',
'replaceNamespacedTimeInterval',
],
exportName: 'generatedTimeIntervalsApi',
flattenArg: false,
},