diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 826d367c82f..5db53386053 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -401,6 +401,16 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts from an Alertmanager instance", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) } + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Alert history", + SubTitle: "History of events that were generated by by your Grafana-managed alert rules. Silences and Mute timmings are ignored.", + Id: "alerts-history", + Url: s.cfg.AppSubURL + "/alerting/history", + Icon: "history", + }) + } + if c.SignedInUser.GetOrgRole() == org.RoleAdmin { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin", diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index c34f680bc8e..b1053160979 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -169,6 +169,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') ), }, + { + path: '/alerting/history/', + roles: evaluateAccess([ + AccessControlAction.AlertingInstanceRead, + AccessControlAction.AlertingInstancesExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "CentralAlertHistory" */ 'app/features/alerting/unified/CentralAlertHistory') + ), + }, { path: '/alerting/new/:type?', pageClass: 'page-alerting', diff --git a/public/app/features/alerting/unified/CentralAlertHistory.tsx b/public/app/features/alerting/unified/CentralAlertHistory.tsx new file mode 100644 index 00000000000..317cf336366 --- /dev/null +++ b/public/app/features/alerting/unified/CentralAlertHistory.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { NavModelItem, getDefaultTimeRange } from '@grafana/data'; +import { isFetchError } from '@grafana/runtime'; +import { Alert, Button, Field, Icon, Input, Label, Stack, withErrorBoundary } from '@grafana/ui'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; + +import { stateHistoryApi } from './api/stateHistoryApi'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { STATE_HISTORY_POLLING_INTERVAL } from './components/rules/state-history/LokiStateHistory'; +import { LogRecord } from './components/rules/state-history/common'; +import { useRuleHistoryRecords } from './components/rules/state-history/useRuleHistoryRecords'; +import { stringifyErrorLike } from './utils/misc'; + +const CentralAlertHistory = (): JSX.Element => { + const { useGetRuleHistoryQuery } = stateHistoryApi; + // Filter state + const [eventsFilter, setEventsFilter] = useState(''); + const { getValues, setValue, register, handleSubmit } = useForm({ defaultValues: { query: '' } }); // form for search field + + const onFilterCleared = useCallback(() => { + setEventsFilter(''); + setValue('query', ''); + }, [setEventsFilter, setValue]); + + // We prefer log count-based limit rather than time-based, but the API doesn't support it yet + const queryTimeRange = useMemo(() => getDefaultTimeRange(), []); + + const { + currentData: stateHistory, + isLoading, + isError, + error, + } = useGetRuleHistoryQuery( + { + from: queryTimeRange.from.unix(), + to: queryTimeRange.to.unix(), + limit: 250, + }, + { + refetchOnFocus: true, + refetchOnReconnect: true, + pollingInterval: STATE_HISTORY_POLLING_INTERVAL, + } + ); + const { historyRecords, findCommonLabels } = useRuleHistoryRecords( + stateHistory + // instancesFilter + ); + const defaultPageNav: NavModelItem = { + id: 'alerts-history', + text: '', + }; + + if (isError) { + return ( + + + + ); + } + + if (isLoading) { + return ( + + <> + + ); + } + + return ( + + +
setEventsFilter(data.query))}> + + + + +
+
+ ); +}; + +interface HistoryLogEntryProps { + logRecords: LogRecord[]; +} +function HistoryLogEntry({ logRecords }: HistoryLogEntryProps) { + // display log records + return ( + + ); +} + +interface HistoryErrorMessageProps { + error: unknown; +} + +function HistoryErrorMessage({ error }: HistoryErrorMessageProps) { + if (isFetchError(error) && error.status === 404) { + return ; + } + + return {stringifyErrorLike(error)}; +} + +interface SearchFieldInputProps { + showClearFilterSuffix: boolean; + onClearFilterClick: () => void; +} +const SearchFieldInput = ({ showClearFilterSuffix, onClearFilterClick }: SearchFieldInputProps) => { + return ( + + + Filter instances + + + } + > + } + suffix={ + showClearFilterSuffix && ( + + ) + } + placeholder="Filter events" + /> + + ); +}; + +export default withErrorBoundary(CentralAlertHistory, { style: 'page' }); diff --git a/public/app/features/alerting/unified/api/stateHistoryApi.ts b/public/app/features/alerting/unified/api/stateHistoryApi.ts index 3a78bcd8425..2f82fe6ef2d 100644 --- a/public/app/features/alerting/unified/api/stateHistoryApi.ts +++ b/public/app/features/alerting/unified/api/stateHistoryApi.ts @@ -4,7 +4,7 @@ import { alertingApi } from './alertingApi'; export const stateHistoryApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ - getRuleHistory: build.query({ + getRuleHistory: build.query({ query: ({ ruleUid, from, to, limit = 100 }) => ({ url: '/api/v1/rules/history', params: { ruleUID: ruleUid, from, to, limit }, diff --git a/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx b/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx index 062ff5b1820..9e15630a93b 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { DataFrame, dateTime, GrafanaTheme2, TimeRange } from '@grafana/data'; -import { Alert, Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { Alert, Button, Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { combineMatcherStrings } from '../../../utils/alertmanager'; @@ -19,7 +19,7 @@ interface Props { ruleUID: string; } -const STATE_HISTORY_POLLING_INTERVAL = 10 * 1000; // 10 seconds +export const STATE_HISTORY_POLLING_INTERVAL = 10 * 1000; // 10 seconds const MAX_TIMELINE_SERIES = 12; const LokiStateHistory = ({ ruleUID }: Props) => { diff --git a/public/app/features/connections/__mocks__/store.navIndex.mock.ts b/public/app/features/connections/__mocks__/store.navIndex.mock.ts index 217f9891655..f135bf3cf83 100644 --- a/public/app/features/connections/__mocks__/store.navIndex.mock.ts +++ b/public/app/features/connections/__mocks__/store.navIndex.mock.ts @@ -149,6 +149,13 @@ export const navIndex: NavIndex = { icon: 'layer-group', url: '/alerting/groups', }, + { + id: 'history', + text: 'History', + subTitle: 'Alert state history', + icon: 'history', + url: '/alerting/history', + }, { id: 'alerting-admin', text: 'Settings',