diff --git a/pkg/api/index.go b/pkg/api/index.go index 2b5a32fdf60..44f9bdb667e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -196,6 +196,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto alertChildNavs := []*dtos.NavLink{ {Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"}, } + if hs.Cfg.IsNgAlertEnabled() { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) + } if c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsNgAlertEnabled() { alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Routes", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"}) } diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index eecfbd90e25..f7ec6696c69 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -31,10 +31,13 @@ export const Page: PageType = ({ navModel, children, className, contentWidth, .. }, [navModel]); return ( -
+
- + {children}
@@ -56,4 +59,9 @@ const getStyles = (theme: GrafanaTheme) => ({ width: 100%; background: ${theme.colors.bg1}; `, + contentWidth: (size: keyof GrafanaTheme['breakpoints']) => css` + .page-container { + max-width: ${theme.breakpoints[size]}; + } + `, }); diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index 8ea37b18000..bf91c5bb862 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,12 +1,11 @@ import React, { FC } from 'react'; -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { Tab, TabsBar, Icon, IconName, useStyles } from '@grafana/ui'; import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme } from '@grafana/data'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { model: NavModel; - contentWidth?: keyof GrafanaTheme['breakpoints']; } const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCss: string }) => { @@ -72,7 +71,7 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ); }; -export const PageHeader: FC = ({ model, contentWidth }) => { +export const PageHeader: FC = ({ model }) => { const styles = useStyles(getStyles); if (!model) { @@ -84,7 +83,7 @@ export const PageHeader: FC = ({ model, contentWidth }) => { return (
-
+
{renderHeaderTitle(main)} {children && children.length && {children}} @@ -143,9 +142,6 @@ const getStyles = (theme: GrafanaTheme) => ({ background: ${theme.colors.bg2}; border-bottom: 1px solid ${theme.colors.border1}; `, - contentWidth: (size: keyof GrafanaTheme['breakpoints']) => css` - max-width: ${theme.breakpoints[size]}; - `, }); export default PageHeader; diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index 9fed789db0c..b1abda9ed87 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -69,6 +69,8 @@ export const RuleList: FC = () => { const grafanaPromError = promRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; const grafanaRulerError = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]?.error; + const showNewAlertSplash = dispatched && !loading && !haveResults; + const combinedNamespaces = useCombinedRuleNamespaces(); const [thresholdNamespaces, systemNamespaces] = useMemo(() => { const sorted = combinedNamespaces @@ -116,13 +118,15 @@ export const RuleList: FC = () => { ))} )} -
- - {dispatched && !loading && !haveResults && } + {!showNewAlertSplash && ( +
+ + )} + {showNewAlertSplash && } {haveResults && } {haveResults && } diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx new file mode 100644 index 00000000000..dd1968712b0 --- /dev/null +++ b/public/app/features/alerting/unified/Silences.tsx @@ -0,0 +1,39 @@ +import { InfoBox, LoadingPlaceholder } from '@grafana/ui'; +import React, { FC, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchSilencesAction } from './state/actions'; +import { initialAsyncRequestState } from './utils/redux'; + +const Silences: FC = () => { + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const dispatch = useDispatch(); + + const silences = useUnifiedAlertingSelector((state) => state.silences); + + useEffect(() => { + dispatch(fetchSilencesAction(alertManagerSourceName)); + }, [alertManagerSourceName, dispatch]); + + const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState; + + return ( + + +
+
+ {error && !loading && ( + Error loading silences}> + {error.message || 'Unknown error.'} + + )} + {loading && } + {result && !loading && !error &&
{JSON.stringify(result, null, 2)}
} +
+ ); +}; + +export default Silences; diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 3ddac795121..eee99169bb6 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -1,5 +1,12 @@ import { getBackendSrv } from '@grafana/runtime'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { + AlertmanagerAlert, + AlertManagerCortexConfig, + AlertmanagerGroup, + Silence, + SilenceCreatePayload, + SilenceMatcher, +} from 'app/plugins/datasource/alertmanager/types'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; // "grafana" for grafana-managed, otherwise a datasource name @@ -37,3 +44,75 @@ export async function updateAlertmanagerConfig( config ); } + +export async function fetchSilences(alertmanagerSourceName: string): Promise { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + return result.data; +} + +// returns the new silence ID. Even in the case of an update, a new silence is created and the previous one expired. +export async function createOrUpdateSilence( + alertmanagerSourceName: string, + payload: SilenceCreatePayload +): Promise { + const result = await getBackendSrv().post( + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`, + payload + ); + return result.data.silenceID; +} + +export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise { + await getBackendSrv().delete( + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences/${encodeURIComponent(silenceID)}` + ); +} + +export async function fetchAlerts( + alertmanagerSourceName: string, + matchers?: SilenceMatcher[] +): Promise { + const filters = + matchers + ?.map( + (matcher) => + `filter=${encodeURIComponent( + `${escapeQuotes(matcher.name)}=${matcher.isRegex ? '~' : ''}"${escapeQuotes(matcher.value)}"` + )}` + ) + .join('&') || ''; + + const result = await getBackendSrv() + .fetch({ + url: + `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/alerts` + + (filters ? '?' + filters : ''), + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + return result.data; +} + +export async function fetchAlertGroups(alertmanagerSourceName: string): Promise { + const result = await getBackendSrv() + .fetch({ + url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/alerts/groups`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + return result.data; +} + +function escapeQuotes(value: string): string { + return value.replace(/"/g, '\\"'); +} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index e1c88e2b1aa..2d9e6f4e8fa 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -1,9 +1,9 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types'; import { ThunkResult } from 'app/types'; import { RuleLocation, RuleNamespace } from 'app/types/unified-alerting'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { fetchAlertManagerConfig } from '../api/alertmanager'; +import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager'; import { fetchRules } from '../api/prometheus'; import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesNamespace, setRulerRuleGroup } from '../api/ruler'; import { getAllRulesSourceNames, isCloudRulesSource } from '../utils/datasource'; @@ -28,6 +28,13 @@ export const fetchRulerRulesAction = createAsyncThunk( } ); +export const fetchSilencesAction = createAsyncThunk( + 'unifiedalerting/fetchSilences', + (alertManagerSourceName: string): Promise => { + return withSerializedError(fetchSilences(alertManagerSourceName)); + } +); + export function fetchAllPromAndRulerRules(force = false): ThunkResult { return (dispatch, getStore) => { const { promRules, rulerRules } = getStore().unifiedAlerting; diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 9eb52a287c9..c09bff1e1fd 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -1,6 +1,11 @@ import { combineReducers } from 'redux'; import { createAsyncMapSlice } from '../utils/redux'; -import { fetchAlertManagerConfigAction, fetchPromRulesAction, fetchRulerRulesAction } from './actions'; +import { + fetchAlertManagerConfigAction, + fetchPromRulesAction, + fetchRulerRulesAction, + fetchSilencesAction, +} from './actions'; export const reducer = combineReducers({ promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, (dataSourceName) => dataSourceName).reducer, @@ -10,6 +15,8 @@ export const reducer = combineReducers({ fetchAlertManagerConfigAction, (alertManagerSourceName) => alertManagerSourceName ).reducer, + silences: createAsyncMapSlice('silences', fetchSilencesAction, (alertManagerSourceName) => alertManagerSourceName) + .reducer, }); export type UnifiedAlertingState = ReturnType; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 308eb9d85b3..5b691d4424e 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -141,3 +141,70 @@ export type AlertmanagerConfig = { inhibit_rules?: InhibitRule[]; receivers?: Receiver[]; }; + +export type SilenceMatcher = { + name: string; + value: string; + isRegex: boolean; +}; + +export enum SilenceState { + Active = 'active', + Expired = 'expired', + Pending = 'pending', +} + +export enum AlertState { + Unprocessed = 'unprocessed', + Active = 'active', + Suppressed = 'suppressed', +} + +export type Silence = { + id: string; + matchers?: SilenceMatcher[]; + startsAt: string; + endsAt: string; + updatedAt: string; + createdBy: string; + comment: string; + status: { + state: SilenceState; + }; +}; + +export type SilenceCreatePayload = { + id?: string; + matchers?: SilenceMatcher[]; + startsAt: string; + endsAt: string; + createdBy: string; + comment: string; +}; + +export type AlertmanagerAlert = { + startsAt: string; + updatedAt: string; + endsAt: string; + generatorURL?: string; + labels: { [key: string]: string }; + annotations: { [key: string]: string }; + receivers: [ + { + name: string; + } + ]; + fingerprint: string; + status: { + state: AlertState; + silencedBy: string[]; + inhibitedBy: string[]; + }; +}; + +export type AlertmanagerGroup = { + labels: { [key: string]: string }; + receiver: { name: string }; + alerts: AlertmanagerAlert[]; + id: string; +}; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 8868d7b4991..c91b56f2625 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -363,6 +363,12 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') ), }, + { + path: '/alerting/silences', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, { path: '/alerting/notifications', component: SafeDynamicImport(