Restructure Silences components for better separation of concerns

This commit is contained in:
Tom Ratcliffe
2024-04-25 16:36:33 +01:00
committed by Tom Ratcliffe
parent 11ed882c84
commit a1d2a9670b
4 changed files with 139 additions and 155 deletions

View File

@@ -2159,11 +2159,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "4"] [0, 0, 0, "Styles should be written using objects.", "4"]
], ],
"public/app/features/alerting/unified/components/silences/SilencesTable.tsx:5381": [ "public/app/features/alerting/unified/components/silences/SilencesTable.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Do not use any type assertions.", "1"]
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
], ],
"public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts:5381": [ "public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]

View File

@@ -1,59 +1,18 @@
import React, { useCallback, useEffect } from 'react'; import React from 'react';
import { Route, RouteChildrenProps, Switch } from 'react-router-dom'; import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import { Alert, withErrorBoundary } from '@grafana/ui'; import { withErrorBoundary } from '@grafana/ui';
import { Silence } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { featureDiscoveryApi } from './api/featureDiscoveryApi';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import SilencesEditor from './components/silences/SilencesEditor'; import SilencesEditor from './components/silences/SilencesEditor';
import SilencesTable from './components/silences/SilencesTable'; import SilencesTable from './components/silences/SilencesTable';
import { useSilenceNavData } from './hooks/useSilenceNavData'; import { useSilenceNavData } from './hooks/useSilenceNavData';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useAlertmanager } from './state/AlertmanagerContext'; import { useAlertmanager } from './state/AlertmanagerContext';
import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions';
import { SILENCES_POLL_INTERVAL_MS } from './utils/constants';
import { AsyncRequestState, initialAsyncRequestState } from './utils/redux';
const Silences = () => { const Silences = () => {
const { selectedAlertmanager } = useAlertmanager(); const { selectedAlertmanager } = useAlertmanager();
const dispatch = useDispatch();
const silences = useUnifiedAlertingSelector((state) => state.silences);
const alertsRequests = useUnifiedAlertingSelector((state) => state.amAlerts);
const alertsRequest = selectedAlertmanager
? alertsRequests[selectedAlertmanager] || initialAsyncRequestState
: undefined;
const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery(
{ amSourceName: selectedAlertmanager ?? '' },
{ skip: !selectedAlertmanager }
);
useEffect(() => {
function fetchAll() {
if (selectedAlertmanager) {
dispatch(fetchSilencesAction(selectedAlertmanager));
dispatch(fetchAmAlertsAction(selectedAlertmanager));
}
}
fetchAll();
const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [selectedAlertmanager, dispatch]);
const { result, loading, error }: AsyncRequestState<Silence[]> =
(selectedAlertmanager && silences[selectedAlertmanager]) || initialAsyncRequestState;
const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]);
const mimirLazyInitError =
error?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit;
if (!selectedAlertmanager) { if (!selectedAlertmanager) {
return null; return null;
} }
@@ -62,48 +21,23 @@ const Silences = () => {
<> <>
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} /> <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} />
{mimirLazyInitError && ( <Switch>
<Alert title="The selected Alertmanager has no configuration" severity="warning"> <Route exact path="/alerting/silences">
Create a new contact point to create a configuration using the default values or contact your administrator to <SilencesTable alertManagerSourceName={selectedAlertmanager} />
set up the Alertmanager. </Route>
</Alert> <Route exact path="/alerting/silence/new">
)} <SilencesEditor alertManagerSourceName={selectedAlertmanager} />
{error && !loading && !mimirLazyInitError && ( </Route>
<Alert severity="error" title="Error loading silences"> <Route exact path="/alerting/silence/:id/edit">
{error.message || 'Unknown error.'} {({ match }: RouteChildrenProps<{ id: string }>) => {
</Alert> return (
)} match?.params.id && (
{alertsRequest?.error && !alertsRequest?.loading && !mimirLazyInitError && ( <SilencesEditor silenceId={match.params.id} alertManagerSourceName={selectedAlertmanager} />
<Alert severity="error" title="Error loading Alertmanager alerts"> )
{alertsRequest.error?.message || 'Unknown error.'} );
</Alert> }}
)} </Route>
{result && !error && ( </Switch>
<Switch>
<Route exact path="/alerting/silences">
<SilencesTable
silences={result}
alertManagerAlerts={alertsRequest?.result ?? []}
alertManagerSourceName={selectedAlertmanager}
/>
</Route>
<Route exact path="/alerting/silence/new">
<SilencesEditor alertManagerSourceName={selectedAlertmanager} />
</Route>
<Route exact path="/alerting/silence/:id/edit">
{({ match }: RouteChildrenProps<{ id: string }>) => {
return (
match?.params.id && (
<SilencesEditor
silence={getSilenceById(match.params.id)}
alertManagerSourceName={selectedAlertmanager}
/>
)
);
}}
</Route>
</Switch>
)}
</> </>
); );
}; };

View File

@@ -1,7 +1,8 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { isEqual, pickBy } from 'lodash'; import { isEqual, pickBy } from 'lodash';
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import { useDebounce } from 'react-use'; import { useDebounce } from 'react-use';
import { import {
@@ -15,25 +16,22 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui'; import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup'; import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { createOrUpdateSilenceAction } from '../../state/actions';
import { SilenceFormFields } from '../../types/silence-form'; import { SilenceFormFields } from '../../types/silence-form';
import { matcherFieldToMatcher, matcherToMatcherField } from '../../utils/alertmanager'; import { matcherFieldToMatcher, matcherToMatcherField } from '../../utils/alertmanager';
import { parseQueryParamMatchers } from '../../utils/matchers'; import { parseQueryParamMatchers } from '../../utils/matchers';
import { makeAMLink } from '../../utils/misc'; import { makeAMLink } from '../../utils/misc';
import { initialAsyncRequestState } from '../../utils/redux';
import MatchersField from './MatchersField'; import MatchersField from './MatchersField';
import { SilencePeriod } from './SilencePeriod'; import { SilencePeriod } from './SilencePeriod';
import { SilencedInstancesPreview } from './SilencedInstancesPreview'; import { SilencedInstancesPreview } from './SilencedInstancesPreview';
interface Props { interface Props {
silence?: Silence; silenceId?: string;
alertManagerSourceName: string; alertManagerSourceName: string;
} }
@@ -97,24 +95,24 @@ const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence):
} }
}; };
export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const history = useHistory();
const [getSilence, { data: silence, isLoading: getSilenceIsLoading }] =
alertSilencesApi.endpoints.getSilence.useLazyQuery();
const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation();
const [urlSearchParams] = useURLSearchParams(); const [urlSearchParams] = useURLSearchParams();
const defaultValues = useMemo(() => getDefaultFormValues(urlSearchParams, silence), [silence, urlSearchParams]); const defaultValues = useMemo(() => getDefaultFormValues(urlSearchParams, silence), [silence, urlSearchParams]);
const formAPI = useForm({ defaultValues }); const formAPI = useForm({ defaultValues });
const dispatch = useDispatch();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [matchersForPreview, setMatchersForPreview] = useState<Matcher[]>( const [matchersForPreview, setMatchersForPreview] = useState<Matcher[]>(
defaultValues.matchers.map(matcherFieldToMatcher) defaultValues.matchers.map(matcherFieldToMatcher)
); );
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence); const { register, handleSubmit, formState, watch, setValue, clearErrors, reset } = formAPI;
useCleanup((state) => (state.unifiedAlerting.updateSilence = initialAsyncRequestState)); const onSubmit = async (data: SilenceFormFields) => {
const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI;
const onSubmit = (data: SilenceFormFields) => {
const { id, startsAt, endsAt, comment, createdBy, matchers: matchersFields } = data; const { id, startsAt, endsAt, comment, createdBy, matchers: matchersFields } = data;
const matchers = matchersFields.map(matcherFieldToMatcher); const matchers = matchersFields.map(matcherFieldToMatcher);
const payload = pickBy( const payload = pickBy(
@@ -128,14 +126,11 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
}, },
(value) => !!value (value) => !!value
) as SilenceCreatePayload; ) as SilenceCreatePayload;
dispatch( await createSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), payload })
createOrUpdateSilenceAction({ .unwrap()
alertManagerSourceName, .then(() => {
payload, history.push(makeAMLink('/alerting/silences', alertManagerSourceName));
exitOnSave: true, });
successMessage: `Silence ${payload.id ? 'updated' : 'created'}`,
})
);
}; };
const duration = watch('duration'); const duration = watch('duration');
@@ -143,6 +138,16 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
const endsAt = watch('endsAt'); const endsAt = watch('endsAt');
const matcherFields = watch('matchers'); const matcherFields = watch('matchers');
useEffect(() => {
reset(getDefaultFormValues(urlSearchParams, silence));
}, [reset, silence, urlSearchParams]);
useEffect(() => {
if (silenceId) {
getSilence({ id: silenceId, datasourceUid: getDatasourceAPIUid(alertManagerSourceName) });
}
}, [alertManagerSourceName, getSilence, silenceId]);
// Keep duration and endsAt in sync // Keep duration and endsAt in sync
const [prevDuration, setPrevDuration] = useState(duration); const [prevDuration, setPrevDuration] = useState(duration);
useDebounce( useDebounce(
@@ -183,10 +188,14 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name); const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name);
if (getSilenceIsLoading) {
return null;
}
return ( return (
<FormProvider {...formAPI}> <FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<FieldSet label={`${silence ? 'Recreate silence' : 'Create silence'}`}> <FieldSet label={`${silenceId ? 'Recreate silence' : 'Create silence'}`}>
<div className={cx(styles.flexRow, styles.silencePeriod)}> <div className={cx(styles.flexRow, styles.silencePeriod)}>
<SilencePeriod /> <SilencePeriod />
<Field <Field
@@ -241,12 +250,12 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => {
<SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchersForPreview} /> <SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchersForPreview} />
</FieldSet> </FieldSet>
<div className={styles.flexRow}> <div className={styles.flexRow}>
{loading && ( {isLoading && (
<Button disabled={true} icon="spinner" variant="primary"> <Button disabled={true} icon="spinner" variant="primary">
Saving... Saving...
</Button> </Button>
)} )}
{!loading && <Button type="submit">Save silence</Button>} {!isLoading && <Button type="submit">Save silence</Button>}
<LinkButton href={makeAMLink('alerting/silences', alertManagerSourceName)} variant={'secondary'}> <LinkButton href={makeAMLink('alerting/silences', alertManagerSourceName)} variant={'secondary'}>
Cancel Cancel
</LinkButton> </LinkButton>

View File

@@ -1,14 +1,17 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { dateMath, GrafanaTheme2 } from '@grafana/data'; import { dateMath, GrafanaTheme2 } from '@grafana/data';
import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack } from '@grafana/ui'; import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack, Alert } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi';
import { featureDiscoveryApi } from 'app/features/alerting/unified/api/featureDiscoveryApi';
import { SILENCES_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants';
import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { expireSilenceAction } from '../../state/actions';
import { parseMatchers } from '../../utils/alertmanager'; import { parseMatchers } from '../../utils/alertmanager';
import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc'; import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize'; import { Authorize } from '../Authorize';
@@ -29,12 +32,30 @@ export interface SilenceTableItem extends Silence {
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>; type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>; type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
interface Props { interface Props {
silences: Silence[];
alertManagerAlerts: AlertmanagerAlert[];
alertManagerSourceName: string; alertManagerSourceName: string;
} }
const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: Props) => { const SilencesTable = ({ alertManagerSourceName }: Props) => {
const [getAmAlerts, { data: alertManagerAlerts, isLoading: amAlertsIsLoading }] =
alertmanagerApi.endpoints.getAlertmanagerAlerts.useLazyQuery({ pollingInterval: SILENCES_POLL_INTERVAL_MS });
const [getSilences, { data: silences = [], isLoading, error }] = alertSilencesApi.endpoints.getSilences.useLazyQuery({
pollingInterval: SILENCES_POLL_INTERVAL_MS,
});
const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery(
{ amSourceName: alertManagerSourceName ?? '' },
{ skip: !alertManagerSourceName }
);
const mimirLazyInitError =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any)?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit;
useEffect(() => {
getSilences({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName) });
getAmAlerts({ amSourceName: alertManagerSourceName });
}, [alertManagerSourceName, getAmAlerts, getSilences]);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const filteredSilencesNotExpired = useFilteredSilences(silences, false); const filteredSilencesNotExpired = useFilteredSilences(silences, false);
@@ -45,7 +66,7 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }:
const itemsNotExpired = useMemo((): SilenceTableItemProps[] => { const itemsNotExpired = useMemo((): SilenceTableItemProps[] => {
const findSilencedAlerts = (id: string) => { const findSilencedAlerts = (id: string) => {
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id));
}; };
return filteredSilencesNotExpired.map((silence) => { return filteredSilencesNotExpired.map((silence) => {
const silencedAlerts = findSilencedAlerts(silence.id); const silencedAlerts = findSilencedAlerts(silence.id);
@@ -58,7 +79,7 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }:
const itemsExpired = useMemo((): SilenceTableItemProps[] => { const itemsExpired = useMemo((): SilenceTableItemProps[] => {
const findSilencedAlerts = (id: string) => { const findSilencedAlerts = (id: string) => {
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id));
}; };
return filteredSilencesExpired.map((silence) => { return filteredSilencesExpired.map((silence) => {
const silencedAlerts = findSilencedAlerts(silence.id); const silencedAlerts = findSilencedAlerts(silence.id);
@@ -69,6 +90,29 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }:
}); });
}, [filteredSilencesExpired, alertManagerAlerts]); }, [filteredSilencesExpired, alertManagerAlerts]);
if (isLoading || amAlertsIsLoading) {
return null;
}
if (mimirLazyInitError) {
return (
<Alert title="The selected Alertmanager has no configuration" severity="warning">
Create a new contact point to create a configuration using the default values or contact your administrator to
set up the Alertmanager.
</Alert>
);
}
if (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errMessage = (error as any)?.message || 'Unknown error.';
return (
<Alert severity="error" title="Error loading silences">
{errMessage}
</Alert>
);
}
return ( return (
<div data-testid="silences-table"> <div data-testid="silences-table">
{!!silences.length && ( {!!silences.length && (
@@ -169,43 +213,43 @@ const useFilteredSilences = (silences: Silence[], expired = false) => {
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
topButtonContainer: css` topButtonContainer: css({
display: flex; display: 'flex',
flex-direction: row; flexDirection: 'row',
justify-content: flex-end; justifyContent: 'flex-end',
`, }),
addNewSilence: css` addNewSilence: css({
margin: ${theme.spacing(2, 0)}; margin: theme.spacing(2, 0),
`, }),
callout: css` callout: css({
background-color: ${theme.colors.background.secondary}; backgroundColor: theme.colors.background.secondary,
border-top: 3px solid ${theme.colors.info.border}; borderTop: `3px solid ${theme.colors.info.border}`,
border-radius: ${theme.shape.radius.default}; borderRadius: theme.shape.radius.default,
height: 62px; height: '62px',
display: flex; display: 'flex',
flex-direction: row; flexDirection: 'row',
align-items: center; alignItems: 'center',
& > * { '& > *': {
margin-left: ${theme.spacing(1)}; marginLeft: theme.spacing(1),
} },
`, }),
calloutIcon: css` calloutIcon: css({
color: ${theme.colors.info.text}; color: theme.colors.info.text,
`, }),
editButton: css` editButton: css({
margin-left: ${theme.spacing(0.5)}; marginLeft: theme.spacing(0.5),
`, }),
}); });
function useColumns(alertManagerSourceName: string) { function useColumns(alertManagerSourceName: string) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence); const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence);
const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation();
return useMemo((): SilenceTableColumnProps[] => { return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (id: string) => { const handleExpireSilenceClick = (silenceId: string) => {
dispatch(expireSilenceAction(alertManagerSourceName, id)); expireSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), silenceId });
}; };
const columns: SilenceTableColumnProps[] = [ const columns: SilenceTableColumnProps[] = [
{ {
@@ -281,6 +325,6 @@ function useColumns(alertManagerSourceName: string) {
}); });
} }
return columns; return columns;
}, [alertManagerSourceName, dispatch, styles.editButton, updateAllowed, updateSupported]); }, [alertManagerSourceName, expireSilence, styles.editButton, updateAllowed, updateSupported]);
} }
export default SilencesTable; export default SilencesTable;