Alerting: use new Alertmanager update hooks for mute and active time intervals (#91404)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { InitialEntry } from 'history';
|
||||
import { last } from 'lodash';
|
||||
import { Route } from 'react-router';
|
||||
import { render, within, userEvent, screen } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
@@ -189,7 +190,7 @@ const saveMuteTiming = async () => {
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const getAlertmanagerConfigUpdate = async (requests: Request[]) => {
|
||||
const getAlertmanagerConfigUpdate = async (requests: Request[]): Promise<AlertManagerCortexConfig> => {
|
||||
const alertmanagerUpdate = requests.find(
|
||||
(r) => r.url.match('/alertmanager/(.*)/config/api/v1/alert') && r.method === 'POST'
|
||||
);
|
||||
@@ -227,8 +228,10 @@ describe('Mute timings', () => {
|
||||
|
||||
const requests = await capture;
|
||||
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
|
||||
const lastAdded = last(alertmanagerUpdate.alertmanager_config.time_intervals);
|
||||
|
||||
// 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');
|
||||
expect(lastAdded?.name).toEqual('maintenance period');
|
||||
});
|
||||
|
||||
it('creates a new mute timing, with time_intervals in config', async () => {
|
||||
@@ -252,7 +255,9 @@ describe('Mute timings', () => {
|
||||
|
||||
const requests = await capture;
|
||||
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
|
||||
expect(alertmanagerUpdate.alertmanager_config.time_intervals.pop().name).toEqual('maintenance period');
|
||||
const lastAdded = last(alertmanagerUpdate.alertmanager_config.time_intervals);
|
||||
|
||||
expect(lastAdded?.name).toEqual('maintenance period');
|
||||
});
|
||||
|
||||
it('creates a new mute timing, with time_intervals and mute_time_intervals in config', async () => {
|
||||
@@ -310,9 +315,9 @@ describe('Mute timings', () => {
|
||||
|
||||
const requests = await capture;
|
||||
const alertmanagerUpdate = await getAlertmanagerConfigUpdate(requests);
|
||||
const mostRecentInterval = alertmanagerUpdate.alertmanager_config.mute_time_intervals.pop().time_intervals[0];
|
||||
const lastAdded = last(alertmanagerUpdate.alertmanager_config.mute_time_intervals);
|
||||
|
||||
expect(mostRecentInterval).toMatchObject({
|
||||
expect(lastAdded?.time_intervals[0]).toMatchObject({
|
||||
days_of_month: [formValues.days],
|
||||
months: formValues.months.split(', '),
|
||||
years: [formValues.years],
|
||||
|
||||
+21
-6
@@ -6,6 +6,7 @@ import { useExportMuteTimingsDrawer } from 'app/features/alerting/unified/compon
|
||||
|
||||
import { Authorize } from '../../components/Authorize';
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { isLoading } from '../../hooks/useAsync';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { isDisabled } from '../../utils/mute-timings';
|
||||
@@ -18,7 +19,9 @@ interface MuteTimingActionsButtonsProps {
|
||||
}
|
||||
|
||||
export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }: MuteTimingActionsButtonsProps) => {
|
||||
const deleteMuteTiming = useDeleteMuteTiming({ alertmanager: alertManagerSourceName! });
|
||||
const [deleteMuteTiming, deleteMuteTimingRequestState] = useDeleteMuteTiming({
|
||||
alertmanager: alertManagerSourceName!,
|
||||
});
|
||||
const [showDeleteDrawer, setShowDeleteDrawer] = useState(false);
|
||||
const [ExportDrawer, showExportDrawer] = useExportMuteTimingsDrawer();
|
||||
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportMuteTimings);
|
||||
@@ -31,7 +34,13 @@ export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }:
|
||||
});
|
||||
|
||||
const viewOrEditButton = (
|
||||
<LinkButton href={viewOrEditHref} variant="secondary" size="sm" icon={muteTiming.provisioned ? 'eye' : 'pen'}>
|
||||
<LinkButton
|
||||
href={viewOrEditHref}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={muteTiming.provisioned ? 'eye' : 'pen'}
|
||||
disabled={isLoading(deleteMuteTimingRequestState)}
|
||||
>
|
||||
{muteTiming.provisioned ? (
|
||||
<Trans i18nKey="alerting.common.view">View</Trans>
|
||||
) : (
|
||||
@@ -52,7 +61,7 @@ export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }:
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid="export"
|
||||
disabled={!exportAllowed}
|
||||
disabled={!exportAllowed || isLoading(deleteMuteTimingRequestState)}
|
||||
onClick={() => showExportDrawer(muteTiming.name)}
|
||||
>
|
||||
<Trans i18nKey="alerting.common.export">Export</Trans>
|
||||
@@ -61,7 +70,13 @@ export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }:
|
||||
|
||||
{!muteTiming.provisioned && (
|
||||
<Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}>
|
||||
<LinkButton icon="trash-alt" variant="secondary" size="sm" onClick={() => setShowDeleteDrawer(true)}>
|
||||
<LinkButton
|
||||
icon="trash-alt"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDrawer(true)}
|
||||
disabled={isLoading(deleteMuteTimingRequestState)}
|
||||
>
|
||||
<Trans i18nKey="alerting.common.delete">Delete</Trans>
|
||||
</LinkButton>
|
||||
</Authorize>
|
||||
@@ -73,8 +88,8 @@ export const MuteTimingActionsButtons = ({ muteTiming, alertManagerSourceName }:
|
||||
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,
|
||||
await deleteMuteTiming.execute({
|
||||
name: muteTiming?.metadata?.name ?? muteTiming.name,
|
||||
});
|
||||
|
||||
closeDeleteModal();
|
||||
|
||||
@@ -60,9 +60,11 @@ const useDefaultValues = (muteTiming?: MuteTiming): MuteTimingFields => {
|
||||
const MuteTimingForm = ({ muteTiming, showError, loading, provisioned, editMode }: Props) => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const hookArgs = { alertmanager: selectedAlertmanager! };
|
||||
const createTimeInterval = useCreateMuteTiming(hookArgs);
|
||||
const updateTimeInterval = useUpdateMuteTiming(hookArgs);
|
||||
|
||||
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.
|
||||
@@ -79,13 +81,13 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provisioned, editMode
|
||||
const returnLink = makeAMLink('/alerting/routes/', selectedAlertmanager!, { tab: 'mute_timings' });
|
||||
|
||||
const onSubmit = async (values: MuteTimingFields) => {
|
||||
const timeInterval = createMuteTiming(values);
|
||||
const interval = createMuteTiming(values);
|
||||
|
||||
const updateOrCreate = async () => {
|
||||
if (editMode) {
|
||||
return updateTimeInterval({ timeInterval, originalName: muteTiming?.metadata?.name || muteTiming!.name });
|
||||
return updateTimeInterval.execute({ interval, originalName: muteTiming?.metadata?.name || muteTiming!.name });
|
||||
}
|
||||
return createTimeInterval({ timeInterval });
|
||||
return createTimeInterval.execute({ interval });
|
||||
};
|
||||
|
||||
return updateOrCreate().then(() => {
|
||||
|
||||
+3
-2
@@ -8,10 +8,11 @@ import {
|
||||
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/k8s/timeIntervals.k8s';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { grantUserPermissions } from '../../mocks';
|
||||
import { TIME_INTERVAL_UID_HAPPY_PATH } from '../../mocks/server/handlers/k8s/timeIntervals.k8s';
|
||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
@@ -80,7 +81,7 @@ describe('MuteTimingsTable', () => {
|
||||
(r) => r.url.includes('/alertmanager/grafana/config/api/v1/alerts') && r.method === 'POST'
|
||||
);
|
||||
|
||||
const body = await amConfigUpdateRequest?.clone().json();
|
||||
const body: AlertManagerCortexConfig = await amConfigUpdateRequest?.clone().json();
|
||||
expect(body.alertmanager_config.mute_time_intervals).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { produce } from 'immer';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi';
|
||||
@@ -8,14 +7,18 @@ import {
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval,
|
||||
ReadNamespacedTimeIntervalApiResponse,
|
||||
} from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen';
|
||||
import { deleteMuteTimingAction, updateAlertManagerConfigAction } from 'app/features/alerting/unified/state/actions';
|
||||
import { BaseAlertmanagerArgs } from 'app/features/alerting/unified/types/hooks';
|
||||
import { renameTimeInterval } from 'app/features/alerting/unified/utils/alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
import { getK8sNamespace, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
|
||||
import {
|
||||
addMuteTimingAction,
|
||||
deleteMuteTimingAction,
|
||||
updateMuteTimingAction,
|
||||
} from '../../reducers/alertmanager/muteTimings';
|
||||
|
||||
const { useLazyGetAlertmanagerConfigurationQuery } = alertmanagerApi;
|
||||
const {
|
||||
@@ -113,6 +116,8 @@ export const useMuteTimings = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
return useK8sApi ? intervalsResponse : configApiResponse;
|
||||
};
|
||||
|
||||
type CreateUpdateMuteTimingArgs = { interval: MuteTimeInterval };
|
||||
|
||||
/**
|
||||
* Create a new mute timing.
|
||||
*
|
||||
@@ -124,40 +129,24 @@ export const useMuteTimings = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
export const useCreateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
const useK8sApi = shouldUseK8sApi(alertmanager);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [createGrafanaTimeInterval] = useCreateNamespacedTimeIntervalMutation();
|
||||
const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery();
|
||||
const [updateConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
|
||||
const isGrafanaAm = alertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
if (useK8sApi) {
|
||||
const addToK8sAPI = useAsync(({ interval }: CreateUpdateMuteTimingArgs) => {
|
||||
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 createGrafanaTimeInterval({
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: { metadata: {}, spec: interval },
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
return dispatch(
|
||||
updateAlertManagerConfigAction({
|
||||
newConfig,
|
||||
oldConfig: result,
|
||||
alertManagerSourceName: alertmanager,
|
||||
successMessage: 'Mute timing saved',
|
||||
})
|
||||
).unwrap();
|
||||
};
|
||||
const addToAlertmanagerConfiguration = useAsync(({ interval }: CreateUpdateMuteTimingArgs) => {
|
||||
const action = addMuteTimingAction({ interval });
|
||||
return updateConfiguration(action);
|
||||
});
|
||||
|
||||
return useK8sApi ? addToK8sAPI : addToAlertmanagerConfiguration;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -223,84 +212,59 @@ export const useGetMuteTiming = ({ alertmanager, name: nameToFind }: BaseAlertma
|
||||
export const useUpdateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
const useK8sApi = shouldUseK8sApi(alertmanager);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [replaceGrafanaTimeInterval] = useReplaceNamespacedTimeIntervalMutation();
|
||||
const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery();
|
||||
const [updateConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
|
||||
if (useK8sApi) {
|
||||
return async ({ timeInterval, originalName }: { timeInterval: MuteTimeInterval; originalName: string }) => {
|
||||
const updateToK8sAPI = useAsync(
|
||||
async ({ interval, originalName }: CreateUpdateMuteTimingArgs & { originalName: string }) => {
|
||||
const namespace = getK8sNamespace();
|
||||
|
||||
return replaceGrafanaTimeInterval({
|
||||
name: originalName,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval: {
|
||||
spec: timeInterval,
|
||||
spec: interval,
|
||||
metadata: { name: originalName },
|
||||
},
|
||||
}).unwrap();
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return async ({ timeInterval, originalName }: { timeInterval: MuteTimeInterval; originalName: string }) => {
|
||||
const nameHasChanged = timeInterval.name !== originalName;
|
||||
const result = await getAlertmanagerConfig(alertmanager).unwrap();
|
||||
const updateToAlertmanagerConfiguration = useAsync(
|
||||
async ({ interval, originalName }: CreateUpdateMuteTimingArgs & { originalName: string }) => {
|
||||
const action = updateMuteTimingAction({ interval, originalName });
|
||||
return updateConfiguration(action);
|
||||
}
|
||||
);
|
||||
|
||||
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 = renameTimeInterval(
|
||||
timeInterval.name,
|
||||
originalName,
|
||||
draft.alertmanager_config.route
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch(
|
||||
updateAlertManagerConfigAction({
|
||||
newConfig,
|
||||
oldConfig: result,
|
||||
alertManagerSourceName: alertmanager,
|
||||
successMessage: 'Mute timing saved',
|
||||
})
|
||||
).unwrap();
|
||||
};
|
||||
return useK8sApi ? updateToK8sAPI : updateToAlertmanagerConfiguration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a mute timing interval
|
||||
*/
|
||||
type DeleteMuteTimingArgs = { name: string };
|
||||
export const useDeleteMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
const useK8sApi = shouldUseK8sApi(alertmanager);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [updateConfiguration, _updateConfigurationRequestState] = useProduceNewAlertmanagerConfiguration();
|
||||
const [deleteGrafanaTimeInterval] = useDeleteNamespacedTimeIntervalMutation();
|
||||
|
||||
if (useK8sApi) {
|
||||
return async ({ name }: { name: string }) => {
|
||||
const namespace = getK8sNamespace();
|
||||
return deleteGrafanaTimeInterval({
|
||||
name,
|
||||
namespace,
|
||||
ioK8SApimachineryPkgApisMetaV1DeleteOptions: {},
|
||||
}).unwrap();
|
||||
};
|
||||
}
|
||||
const deleteFromAlertmanagerAPI = useAsync(async ({ name }: DeleteMuteTimingArgs) => {
|
||||
const action = deleteMuteTimingAction({ name });
|
||||
return updateConfiguration(action);
|
||||
});
|
||||
|
||||
return async ({ name }: { name: string }) => dispatch(deleteMuteTimingAction(alertmanager, name));
|
||||
const deleteFromK8sAPI = useAsync(async ({ name }: DeleteMuteTimingArgs) => {
|
||||
const namespace = getK8sNamespace();
|
||||
await deleteGrafanaTimeInterval({
|
||||
name,
|
||||
namespace,
|
||||
ioK8SApimachineryPkgApisMetaV1DeleteOptions: {},
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
return useK8sApi ? deleteFromK8sAPI : deleteFromAlertmanagerAPI;
|
||||
};
|
||||
|
||||
export const useValidateMuteTiming = ({ alertmanager }: BaseAlertmanagerArgs) => {
|
||||
|
||||
+18
-5
@@ -1,8 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { useForm, Controller, useFieldArray } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -20,9 +20,9 @@ import { ContactPointSelector } from 'app/features/alerting/unified/components/n
|
||||
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
|
||||
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { useMuteTimingOptions } from '../../hooks/useMuteTimingOptions';
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { FormAmRoute } from '../../types/amroutes';
|
||||
import { matcherFieldOptions } from '../../utils/alertmanager';
|
||||
import { matcherFieldOptions, timeIntervalToString } from '../../utils/alertmanager';
|
||||
import {
|
||||
amRouteToFormAmRoute,
|
||||
commonGroupByOptions,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stringToSelectableValue,
|
||||
stringsToSelectableValues,
|
||||
} from '../../utils/amroutes';
|
||||
import { useMuteTimings } from '../mute-timings/useMuteTimings';
|
||||
|
||||
import { PromDurationInput } from './PromDurationInput';
|
||||
import { getFormStyles } from './formStyles';
|
||||
@@ -48,9 +49,21 @@ export interface AmRoutesExpandedFormProps {
|
||||
export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults }: AmRoutesExpandedFormProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const formStyles = useStyles2(getFormStyles);
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route?.group_by));
|
||||
const muteTimingOptions = useMuteTimingOptions();
|
||||
const emptyMatcher = [{ name: '', operator: MatcherOperator.equal, value: '' }];
|
||||
const { data: muteTimings } = useMuteTimings({ alertmanager: selectedAlertmanager! });
|
||||
|
||||
const muteTimingOptions = useMemo(() => {
|
||||
const muteTimingsOptions: Array<SelectableValue<string>> =
|
||||
muteTimings?.map((value) => ({
|
||||
value: value.name,
|
||||
label: value.name,
|
||||
description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '),
|
||||
})) ?? [];
|
||||
|
||||
return muteTimingsOptions;
|
||||
}, [muteTimings]);
|
||||
|
||||
const formAmRoute = {
|
||||
...amRouteToFormAmRoute(route),
|
||||
|
||||
@@ -742,16 +742,18 @@ const TimeIntervals: FC<{ timings: string[]; alertManagerSourceName: string }> =
|
||||
*/
|
||||
return (
|
||||
<div>
|
||||
{timings.map((timing) => (
|
||||
<TextLink
|
||||
key={timing}
|
||||
href={createMuteTimingLink(timing, alertManagerSourceName)}
|
||||
color="primary"
|
||||
variant="bodySmall"
|
||||
inline={false}
|
||||
>
|
||||
{timing}
|
||||
</TextLink>
|
||||
{timings.map((timing, index) => (
|
||||
<Fragment key={timing}>
|
||||
<TextLink
|
||||
href={createMuteTimingLink(timing, alertManagerSourceName)}
|
||||
color="primary"
|
||||
variant="bodySmall"
|
||||
inline={false}
|
||||
>
|
||||
{timing}
|
||||
</TextLink>
|
||||
{index < timings.length - 1 && ', '}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface RequestState {
|
||||
error?: unknown;
|
||||
|
||||
isUninitialized: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// @TODO what to do with the other props that we get from RTKQ's state such as originalArgs, etc?
|
||||
export function mergeRequestStates(...states: RequestState[]): RequestState {
|
||||
return {
|
||||
error: states.find((s) => s.error),
|
||||
isUninitialized: states.every((s) => s.isUninitialized),
|
||||
isSuccess: states.every((s) => s.isSuccess),
|
||||
isError: states.some((s) => s.isError),
|
||||
isLoading: states.some((s) => s.isLoading),
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { mergeTimeIntervals } from '../components/mute-timings/util';
|
||||
import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||
import { timeIntervalToString } from '../utils/alertmanager';
|
||||
|
||||
import { useAlertmanagerConfig } from './useAlertmanagerConfig';
|
||||
|
||||
export function useMuteTimingOptions(): Array<SelectableValue<string>> {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { currentData } = useAlertmanagerConfig(selectedAlertmanager);
|
||||
const config = currentData?.alertmanager_config;
|
||||
|
||||
return useMemo(() => {
|
||||
const time_intervals = config ? mergeTimeIntervals(config) : [];
|
||||
const muteTimingsOptions: Array<SelectableValue<string>> =
|
||||
time_intervals?.map((value) => ({
|
||||
value: value.name,
|
||||
label: value.name,
|
||||
description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '),
|
||||
})) ?? [];
|
||||
|
||||
return muteTimingsOptions;
|
||||
}, [config]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import reduceReducers from 'reduce-reducers';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
|
||||
import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||
|
||||
import { mergeRequestStates } from './mergeRequestStates';
|
||||
@@ -24,7 +25,7 @@ export const initialAlertmanagerConfiguration: AlertManagerCortexConfig = {
|
||||
template_files: {},
|
||||
};
|
||||
|
||||
const configurationReducer = reduceReducers(initialAlertmanagerConfiguration);
|
||||
const configurationReducer = reduceReducers(initialAlertmanagerConfiguration, muteTimingsReducer);
|
||||
|
||||
/**
|
||||
* This hook will make sure we are always applying actions that mutate the Alertmanager configuration
|
||||
@@ -52,6 +53,7 @@ export function useProduceNewAlertmanagerConfiguration() {
|
||||
*/
|
||||
const produceNewAlertmanagerConfiguration = async (action: Action) => {
|
||||
const currentAlertmanagerConfiguration = await fetchAlertmanagerConfig(selectedAlertmanager).unwrap();
|
||||
|
||||
const newConfig = configurationReducer(currentAlertmanagerConfiguration, action);
|
||||
|
||||
return updateAlertManager({
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`mute timings should be able to add a new mute timing 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [
|
||||
{
|
||||
"name": "default legacy time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"route": {
|
||||
"routes": [
|
||||
{
|
||||
"mute_time_intervals": [
|
||||
"default time interval",
|
||||
],
|
||||
},
|
||||
{
|
||||
"mute_time_intervals": [
|
||||
"default legacy time interval",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [
|
||||
{
|
||||
"name": "default time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "new mute time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`mute timings should be able to remove a mute timing 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [],
|
||||
"route": {
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [],
|
||||
"routes": [
|
||||
{
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [
|
||||
"default time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
{
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [],
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [
|
||||
{
|
||||
"name": "default time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`mute timings should be able to remove a mute timing 2`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [
|
||||
{
|
||||
"name": "default legacy time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"route": {
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [],
|
||||
"routes": [
|
||||
{
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [],
|
||||
"routes": undefined,
|
||||
},
|
||||
{
|
||||
"active_time_intervals": [],
|
||||
"mute_time_intervals": [
|
||||
"default legacy time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`mute timings should be able to update a time interval 1`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [
|
||||
{
|
||||
"name": "new mute time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"route": {
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": undefined,
|
||||
"routes": [
|
||||
{
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": [
|
||||
"default time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
{
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": [
|
||||
"new mute time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [
|
||||
{
|
||||
"name": "default time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`mute timings should be able to update a time interval 2`] = `
|
||||
{
|
||||
"alertmanager_config": {
|
||||
"mute_time_intervals": [
|
||||
{
|
||||
"name": "default legacy time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"route": {
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": undefined,
|
||||
"routes": [
|
||||
{
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": [
|
||||
"new mute time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
{
|
||||
"active_time_intervals": undefined,
|
||||
"mute_time_intervals": [
|
||||
"default legacy time interval",
|
||||
],
|
||||
"routes": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"time_intervals": [
|
||||
{
|
||||
"name": "new mute time interval",
|
||||
"time_intervals": [
|
||||
{
|
||||
"days_of_month": [
|
||||
"15",
|
||||
],
|
||||
"months": [
|
||||
"august:december",
|
||||
"march",
|
||||
],
|
||||
"times": [
|
||||
{
|
||||
"end_time": "24:00",
|
||||
"start_time": "12:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"template_files": {},
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UnknownAction } from 'redux';
|
||||
|
||||
import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { addMuteTimingAction, deleteMuteTimingAction, muteTimingsReducer, updateMuteTimingAction } from './muteTimings';
|
||||
|
||||
describe('mute timings', () => {
|
||||
const initialConfig: AlertManagerCortexConfig = {
|
||||
alertmanager_config: {
|
||||
time_intervals: [mockTimeInterval({ name: 'default time interval' })],
|
||||
mute_time_intervals: [mockTimeInterval({ name: 'default legacy time interval' })],
|
||||
route: {
|
||||
routes: [
|
||||
{
|
||||
mute_time_intervals: ['default time interval'],
|
||||
},
|
||||
{
|
||||
mute_time_intervals: ['default legacy time interval'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
template_files: {},
|
||||
};
|
||||
|
||||
it('should be able to add a new mute timing', () => {
|
||||
const newMuteTiming = mockTimeInterval({ name: 'new mute time interval' });
|
||||
const action = addMuteTimingAction({ interval: newMuteTiming });
|
||||
|
||||
expect(muteTimingsReducer(initialConfig, action)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should be able to remove a mute timing', () => {
|
||||
const legacyMuteTimingName = initialConfig.alertmanager_config.mute_time_intervals![0].name;
|
||||
const deleteFromLegacyKey = deleteMuteTimingAction({ name: legacyMuteTimingName });
|
||||
expect(muteTimingsReducer(initialConfig, deleteFromLegacyKey)).toMatchSnapshot();
|
||||
|
||||
const muteTimingName = initialConfig.alertmanager_config.time_intervals![0].name;
|
||||
const deleteMuteTiming = deleteMuteTimingAction({ name: muteTimingName });
|
||||
expect(muteTimingsReducer(initialConfig, deleteMuteTiming)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should be able to update a time interval', () => {
|
||||
const newMuteTiming = mockTimeInterval({ name: 'new mute time interval' });
|
||||
|
||||
const legacyMuteTimingName = initialConfig.alertmanager_config.mute_time_intervals![0].name;
|
||||
const updateLegacyMuteTiming = updateMuteTimingAction({
|
||||
originalName: legacyMuteTimingName,
|
||||
interval: newMuteTiming,
|
||||
});
|
||||
expect(muteTimingsReducer(initialConfig, updateLegacyMuteTiming)).toMatchSnapshot();
|
||||
|
||||
const muteTimingName = initialConfig.alertmanager_config.time_intervals![0].name;
|
||||
const updateMuteTiming = updateMuteTimingAction({ originalName: muteTimingName, interval: newMuteTiming });
|
||||
expect(muteTimingsReducer(initialConfig, updateMuteTiming)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should throw for unknown action', () => {
|
||||
const action: UnknownAction = { type: 'unknown' };
|
||||
|
||||
expect(() => {
|
||||
muteTimingsReducer(initialConfig, action);
|
||||
}).toThrow('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
function mockTimeInterval(overrides: Partial<MuteTimeInterval> = {}): MuteTimeInterval {
|
||||
return {
|
||||
name: 'mock time interval',
|
||||
time_intervals: [
|
||||
{
|
||||
times: [
|
||||
{
|
||||
start_time: '12:00',
|
||||
end_time: '24:00',
|
||||
},
|
||||
],
|
||||
days_of_month: ['15'],
|
||||
months: ['august:december', 'march'],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
import { remove } from 'lodash';
|
||||
|
||||
import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { removeTimeIntervalFromRoute, renameTimeInterval } from '../../utils/alertmanager';
|
||||
|
||||
export const addMuteTimingAction = createAction<{ interval: MuteTimeInterval }>('muteTiming/add');
|
||||
export const updateMuteTimingAction = createAction<{
|
||||
interval: MuteTimeInterval;
|
||||
originalName: string;
|
||||
}>('muteTiming/update');
|
||||
export const deleteMuteTimingAction = createAction<{ name: string }>('muteTiming/delete');
|
||||
|
||||
const initialState: AlertManagerCortexConfig = {
|
||||
alertmanager_config: {},
|
||||
template_files: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* This reducer will manage action related to mute timings and make sure all operations on the alertmanager
|
||||
* configuration happen immutably and only mutate what they need.
|
||||
*/
|
||||
export const muteTimingsReducer = createReducer(initialState, (builder) => {
|
||||
builder
|
||||
// add a mute timing to the alertmanager configuration
|
||||
.addCase(addMuteTimingAction, (draft, { payload }) => {
|
||||
const { interval } = payload;
|
||||
draft.alertmanager_config.time_intervals = (draft.alertmanager_config.time_intervals ?? []).concat(interval);
|
||||
})
|
||||
// add a mute timing to the alertmanager configuration
|
||||
// make sure we update the mute timing in either the deprecated or the new time intervals property
|
||||
.addCase(updateMuteTimingAction, (draft, { payload }) => {
|
||||
const { interval, originalName } = payload;
|
||||
const nameHasChanged = interval.name !== originalName;
|
||||
|
||||
const timeIntervals = draft.alertmanager_config.time_intervals ?? [];
|
||||
const muteTimeIntervals = draft.alertmanager_config.mute_time_intervals ?? [];
|
||||
|
||||
const existingIntervalIndex = timeIntervals.findIndex(({ name }) => name === originalName);
|
||||
if (existingIntervalIndex !== -1) {
|
||||
timeIntervals[existingIntervalIndex] = interval;
|
||||
}
|
||||
|
||||
const existingMuteIntervalIndex = muteTimeIntervals.findIndex(({ name }) => name === originalName);
|
||||
if (existingMuteIntervalIndex !== -1) {
|
||||
muteTimeIntervals[existingMuteIntervalIndex] = interval;
|
||||
}
|
||||
|
||||
if (nameHasChanged && draft.alertmanager_config.route) {
|
||||
draft.alertmanager_config.route = renameTimeInterval(
|
||||
interval.name,
|
||||
originalName,
|
||||
draft.alertmanager_config.route
|
||||
);
|
||||
}
|
||||
})
|
||||
// delete a mute timing from the alertmanager configuration, since the configuration might be using the "deprecated" mute_time_intervals
|
||||
// let's also check there
|
||||
.addCase(deleteMuteTimingAction, (draft, { payload }) => {
|
||||
const { name } = payload;
|
||||
const { alertmanager_config } = draft;
|
||||
const { time_intervals = [], mute_time_intervals = [] } = alertmanager_config;
|
||||
|
||||
// remove the mute timings from the legacy and new time intervals definition
|
||||
remove(time_intervals, (interval) => interval.name === name);
|
||||
remove(mute_time_intervals, (interval) => interval.name === name);
|
||||
|
||||
// remove the mute timing from all routes
|
||||
alertmanager_config.route = removeTimeIntervalFromRoute(name, alertmanager_config.route ?? {});
|
||||
})
|
||||
.addDefaultCase((_state, action) => {
|
||||
throw new Error(`Unknown action for mute timing reducer: ${action.type}`);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ import { discoverFeatures } from '../api/buildInfo';
|
||||
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
|
||||
import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { addDefaultsToAlertmanagerConfig, removeTimeIntervalFromRoute } from '../utils/alertmanager';
|
||||
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
||||
import {
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
getAllRulesSourceNames,
|
||||
@@ -557,54 +557,6 @@ export const deleteAlertManagerConfigAction = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimingName: string): ThunkResult<void> => {
|
||||
return async (dispatch) => {
|
||||
const config = await dispatch(
|
||||
alertmanagerApi.endpoints.getAlertmanagerConfiguration.initiate(alertManagerSourceName)
|
||||
).unwrap();
|
||||
|
||||
const isGrafanaDatasource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const muteIntervalsFiltered =
|
||||
(config?.alertmanager_config?.mute_time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? [];
|
||||
const timeIntervalsFiltered =
|
||||
(config?.alertmanager_config?.time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? [];
|
||||
|
||||
const time_intervals_without_mute_to_save = isGrafanaDatasource
|
||||
? {
|
||||
mute_time_intervals: [...muteIntervalsFiltered, ...timeIntervalsFiltered],
|
||||
}
|
||||
: {
|
||||
time_intervals: timeIntervalsFiltered,
|
||||
mute_time_intervals: muteIntervalsFiltered,
|
||||
};
|
||||
|
||||
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
|
||||
? removeTimeIntervalFromRoute(muteTimingName, config.alertmanager_config?.route)
|
||||
: undefined,
|
||||
...time_intervals_without_mute_to_save,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
{
|
||||
successMessage: `Deleted "${muteTimingName}" from Alertmanager configuration`,
|
||||
errorMessage: 'Failed to delete mute timing',
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
interface TestReceiversOptions {
|
||||
alertManagerSourceName: string;
|
||||
receivers: Receiver[];
|
||||
|
||||
Reference in New Issue
Block a user