diff --git a/.betterer.results b/.betterer.results index 5b95019b8fc..26ae030adbb 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1464,8 +1464,7 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [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.", "2"] ], "public/app/features/alerting/unified/components/Authorize.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/core/components/Page/PageTabs.tsx b/public/app/core/components/Page/PageTabs.tsx index d10e289e524..b25223d14e2 100644 --- a/public/app/core/components/Page/PageTabs.tsx +++ b/public/app/core/components/Page/PageTabs.tsx @@ -26,6 +26,7 @@ export function PageTabs({ navItem }: Props) { counter={child.tabCounter} href={child.url} suffix={child.tabSuffix} + onChangeTab={child.onClick} /> ) ); diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx index 87754cb6c17..d086bfabc13 100644 --- a/public/app/features/alerting/unified/Admin.tsx +++ b/public/app/features/alerting/unified/Admin.tsx @@ -8,7 +8,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; export default function Admin(): JSX.Element { return ( - + ); diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx index addf2cbd29a..1f1d720616c 100644 --- a/public/app/features/alerting/unified/AlertGroups.tsx +++ b/public/app/features/alerting/unified/AlertGroups.tsx @@ -91,7 +91,7 @@ const AlertGroups = () => { }; const AlertGroupsPage = () => ( - + ); diff --git a/public/app/features/alerting/unified/MuteTimings.tsx b/public/app/features/alerting/unified/MuteTimings.tsx index 3900a8ccf5e..c4d1584380c 100644 --- a/public/app/features/alerting/unified/MuteTimings.tsx +++ b/public/app/features/alerting/unified/MuteTimings.tsx @@ -78,7 +78,7 @@ const MuteTimingsPage = () => { const pageNav = useMuteTimingNavData(); return ( - + ); diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index d31c34a36d0..8c66e0222d2 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -329,7 +329,7 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues { } const NotificationPoliciesPage = () => ( - + ); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 6de84d52b62..49d69693365 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -17,7 +17,7 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; const ContactPoints = (_props: GrafanaRouteComponentProps): JSX.Element => ( - + diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 1bc056c0b5e..f2c82cf9187 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -90,7 +90,7 @@ const RuleEditor = ({ match }: RuleEditorProps) => { }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]); return ( - + {getContent()} ); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index de55f35e0cd..dfc7078d9aa 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -105,7 +105,7 @@ const RuleList = withErrorBoundary( return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - + {!hasNoAlertRulesCreatedYet && ( diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 9f994e2ab51..d81bcd3fedf 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { withErrorBoundary } from '@grafana/ui'; +import { Alert, withErrorBoundary } from '@grafana/ui'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { useCombinedRule } from './hooks/useCombinedRule'; +import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1')); -const DetailViewV2 = SafeDynamicImport(() => import('./components/rule-viewer/v2/RuleViewer.v2')); +const DetailViewV2 = React.lazy(() => import('./components/rule-viewer/v2/RuleViewer.v2')); type RuleViewerProps = GrafanaRouteComponentProps<{ id: string; @@ -17,10 +20,51 @@ type RuleViewerProps = GrafanaRouteComponentProps<{ const newAlertDetailView = Boolean(config.featureToggles.alertingDetailsViewV2) === true; -const RuleViewer = (props: RuleViewerProps): JSX.Element => ( - - {newAlertDetailView ? : } - -); +const RuleViewer = (props: RuleViewerProps): JSX.Element => { + return newAlertDetailView ? : ; +}; + +export const defaultPageNav: NavModelItem = { + id: 'alert-rule-view', + text: '', +}; + +const RuleViewerV1Wrapper = (props: RuleViewerProps) => ; + +const RuleViewerV2Wrapper = (props: RuleViewerProps) => { + const id = getRuleIdFromPathname(props.match.params); + const identifier = useMemo(() => { + if (!id) { + throw new Error('Rule ID is required'); + } + + return parseRuleId(id, true); + }, [id]); + + const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); + + // TODO improve error handling here + if (error) { + if (typeof error === 'string') { + return error; + } + + return Something went wrong loading the rule; + } + + if (loading) { + return ( + + <> + + ); + } + + if (rule) { + return ; + } + + return null; +}; export default withErrorBoundary(RuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index 1d4dfaf8e25..ec4addf133e 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -112,7 +112,7 @@ function SilencesPage() { const pageNav = useSilenceNavData(); return ( - + ); diff --git a/public/app/features/alerting/unified/components/AlertStateDot.tsx b/public/app/features/alerting/unified/components/AlertStateDot.tsx index bdd0dbedbe1..01a991d6450 100644 --- a/public/app/features/alerting/unified/components/AlertStateDot.tsx +++ b/public/app/features/alerting/unified/components/AlertStateDot.tsx @@ -2,26 +2,28 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { ComponentSize, Tooltip, useStyles2 } from '@grafana/ui'; -import { GrafanaAlertState } from 'app/types/unified-alerting-dto'; +import { ComponentSize, Stack, useStyles2 } from '@grafana/ui'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; const AlertStateDot = (props: DotStylesProps) => { const styles = useStyles2(getDotStyles, props); return ( - +
- + ); }; interface DotStylesProps { - state?: GrafanaAlertState; + state: PromAlertingRuleState; + includeState?: boolean; size?: ComponentSize; // TODO support this } const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { const size = theme.spacing(1.25); + const outlineSize = `calc(${size} / 2.5)`; return { dot: css` @@ -31,21 +33,22 @@ const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { border-radius: 100%; background-color: ${theme.colors.secondary.main}; - outline: solid calc(${size} / 2.5) ${theme.colors.secondary.transparent}; + outline: solid ${outlineSize} ${theme.colors.secondary.transparent}; + margin: ${outlineSize}; - ${props.state === GrafanaAlertState.Normal && + ${props.state === PromAlertingRuleState.Inactive && css` background-color: ${theme.colors.success.main}; outline-color: ${theme.colors.success.transparent}; `} - ${props.state === GrafanaAlertState.Pending && + ${props.state === PromAlertingRuleState.Pending && css` background-color: ${theme.colors.warning.main}; outline-color: ${theme.colors.warning.transparent}; `} - ${props.state === GrafanaAlertState.Alerting && + ${props.state === PromAlertingRuleState.Firing && css` background-color: ${theme.colors.error.main}; outline-color: ${theme.colors.error.transparent}; diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index b4c59b4841d..62f9069b8db 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -1,8 +1,8 @@ import React, { PropsWithChildren } from 'react'; import { useLocation } from 'react-use'; -import { NavModelItem } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; +import { PageProps } from 'app/core/components/Page/types'; import { AlertmanagerProvider, useAlertmanager } from '../state/AlertmanagerContext'; @@ -12,19 +12,15 @@ import { NoAlertManagerWarning } from './NoAlertManagerWarning'; /** * This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view */ -interface AlertingPageWrapperProps extends PropsWithChildren { - pageId?: string; +interface AlertingPageWrapperProps extends PageProps { isLoading?: boolean; - pageNav?: NavModelItem; - actions?: React.ReactNode; } -export const AlertingPageWrapper = ({ children, pageId, pageNav, actions, isLoading }: AlertingPageWrapperProps) => { - return ( - - {children} - - ); -}; + +export const AlertingPageWrapper = ({ children, isLoading, ...rest }: AlertingPageWrapperProps) => ( + + {children} + +); /** * This wrapper is for pages that use the Alertmanager API diff --git a/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx index d29f0fe2f2c..d3ce186f377 100644 --- a/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx +++ b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, textUtil } from '@grafana/data'; -import { Tooltip, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { TextLink, Tooltip, useStyles2 } from '@grafana/ui'; import { Annotation, annotationLabels } from '../utils/constants'; @@ -44,9 +44,9 @@ const AnnotationValue = ({ annotationKey, value, valueLink }: Props) => { if (valueLink) { return ( - + {value} - + ); } @@ -56,9 +56,9 @@ const AnnotationValue = ({ annotationKey, value, valueLink }: Props) => { if (needsExternalLink) { return ( - + {value} - + ); } @@ -69,8 +69,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({ well: css` word-break: break-word; `, - link: css` - word-break: break-all; - color: ${theme.colors.primary.text}; - `, }); diff --git a/public/app/features/alerting/unified/components/MetaText.tsx b/public/app/features/alerting/unified/components/MetaText.tsx index f2f5764dd82..3d88d8cd9da 100644 --- a/public/app/features/alerting/unified/components/MetaText.tsx +++ b/public/app/features/alerting/unified/components/MetaText.tsx @@ -5,13 +5,18 @@ import { Icon, IconName, useStyles2, Text, Stack } from '@grafana/ui'; interface Props extends HTMLAttributes { icon?: IconName; + direction?: 'row' | 'column'; color?: ComponentProps['color']; } -const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => { +const MetaText = ({ children, icon, color = 'secondary', direction = 'row', ...rest }: Props) => { const styles = useStyles2(getStyles); const interactive = typeof rest.onClick === 'function'; + const rowDirection = direction === 'row'; + const alignItems = rowDirection ? 'center' : 'flex-start'; + const gap = rowDirection ? 0.5 : 0; + return (
{ {...rest} > - + {icon && } {children} diff --git a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx index 05bb7fe83df..fab8db3c9d5 100644 --- a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx +++ b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx @@ -1,6 +1,6 @@ -import React, { FC } from 'react'; +import React from 'react'; -import { Button, LinkButton, Tooltip } from '@grafana/ui'; +import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui'; import { usePluginBridge } from '../../hooks/usePluginBridge'; import { SupportedPlugin } from '../../types/pluginBridges'; @@ -8,11 +8,11 @@ import { createBridgeURL } from '../PluginBridge'; interface Props { title?: string; - severity?: 'minor' | 'major' | 'critical'; + severity?: 'minor' | 'major' | 'critical' | ''; url?: string; } -export const DeclareIncident: FC = ({ title = '', severity = '', url = '' }) => { +export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => { const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity, url }); const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident); @@ -39,3 +39,21 @@ export const DeclareIncident: FC = ({ title = '', severity = '', url = '' ); }; + +export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => { + const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity, url }); + + const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident); + + return ( + <> + {loading === true && } + {installed === false && ( + + + + )} + {settings && } + + ); +}; diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx index 02a671b35ea..3e6a3b62c76 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx @@ -66,7 +66,7 @@ export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) if (!alertRule && !loading && !loadingBuildInfo) { // alert rule does not exist return ( - + + ; const errorMessage = 'Could not find data source for rule'; @@ -63,58 +61,8 @@ export function RuleViewer({ match }: RuleViewerProps) { const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); - const runner = useMemo(() => new AlertingQueryRunner(), []); - const data = useObservable(runner.get()); - const queries = useMemo(() => alertRuleToQueries(rule), [rule]); const annotations = useCleanAnnotations(rule?.annotations || {}); - const [evaluationTimeRanges, setEvaluationTimeRanges] = useState>({}); - - const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); - - const onRunQueries = useCallback(() => { - if (queries.length > 0 && allDataSourcesAvailable) { - const evalCustomizedQueries = queries.map((q) => ({ - ...q, - relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange, - })); - let condition; - if (rule && isGrafanaRulerRule(rule.rulerRule)) { - condition = rule.rulerRule.grafana_alert.condition; - } - runner.run(evalCustomizedQueries, condition ?? 'A'); - } - }, [queries, evaluationTimeRanges, runner, allDataSourcesAvailable, rule]); - - useEffect(() => { - const alertQueries = alertRuleToQueries(rule); - const defaultEvalTimeRanges = Object.fromEntries( - alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }]) - ); - - setEvaluationTimeRanges(defaultEvalTimeRanges); - }, [rule]); - - useEffect(() => { - if (allDataSourcesAvailable && expandQuery) { - onRunQueries(); - } - }, [onRunQueries, allDataSourcesAvailable, expandQuery]); - - useEffect(() => { - return () => runner.destroy(); - }, [runner]); - - const onQueryTimeRangeChange = useCallback( - (refId: string, timeRange: RelativeTimeRange) => { - const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => { - draft[refId] = timeRange; - }); - setEvaluationTimeRanges(newEvalTimeRanges); - }, - [evaluationTimeRanges, setEvaluationTimeRanges] - ); - if (!identifier?.ruleSourceName) { return ( @@ -160,7 +108,17 @@ export function RuleViewer({ match }: RuleViewerProps) { const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); return ( - <> + ( + + + {rule.name} + + + )} + > {isFederatedRule && ( @@ -174,14 +132,8 @@ export function RuleViewer({ match }: RuleViewerProps) { )} {isProvisioned && } - -
- - {rule.name} - - - -
+ <> +
{rule.promRule && ( @@ -207,57 +159,25 @@ export function RuleViewer({ match }: RuleViewerProps) {
- + + +
-
+ - {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( - - )} - - {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( -
- {queries.map((query) => { - return ( - ds.uid === query.datasourceUid)} - queryData={data[query.refId]} - relativeTimeRange={query.relativeTimeRange} - evalTimeRange={evaluationTimeRanges[query.refId]} - onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} - isAlertCondition={false} - /> - ); - })} -
- )} - {!isFederatedRule && !allDataSourcesAvailable && ( - - Cannot display the query preview. Some of the data sources used in the queries are not available. - - )} + {expandQuery && }
- +
); } @@ -272,10 +192,6 @@ function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) { ); } -function isLoading(data: Record): boolean { - return !!Object.values(data).find((d) => d.state === LoadingState.Loading); -} - const getStyles = (theme: GrafanaTheme2) => { return { errorMessage: css` diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx index e3a4c435f46..1fb81e436b4 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx @@ -4,10 +4,12 @@ import React from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { PageProps } from 'app/core/components/Page/types'; type Props = { children: React.ReactNode | React.ReactNode[]; title: string; + renderTitle?: PageProps['renderTitle']; wrapInContent?: boolean; }; @@ -17,11 +19,11 @@ const defaultPageNav: Partial = { }; export function RuleViewerLayout(props: Props): JSX.Element | null { - const { wrapInContent = true, children, title } = props; + const { wrapInContent = true, children, title, renderTitle } = props; const styles = useStyles2(getPageStyles); return ( - +
{wrapInContent ? : children}
diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx new file mode 100644 index 00000000000..1c0da4966ad --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -0,0 +1,165 @@ +import { css } from '@emotion/css'; +import { formatDistanceToNowStrict } from 'date-fns'; +import React, { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Text, Stack, useStyles2, ClipboardButton, TextLink } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { Annotations } from 'app/types/unified-alerting-dto'; + +import { isGrafanaRulerRule, isRecordingRulerRule } from '../../../utils/rules'; +import { MetaText } from '../../MetaText'; +import { Tokenize } from '../../Tokenize'; + +interface DetailsProps { + rule: CombinedRule; +} + +enum RuleType { + GrafanaManagedAlertRule = 'Grafana-managed alert rule', + CloudAlertRule = 'Cloud alert rule', + CloudRecordingRule = 'Cloud recording rule', +} + +const Details = ({ rule }: DetailsProps) => { + const styles = useStyles2(getStyles); + + let ruleType: RuleType; + + if (isGrafanaRulerRule(rule.rulerRule)) { + ruleType = RuleType.GrafanaManagedAlertRule; + } else if (isRecordingRulerRule(rule.rulerRule)) { + ruleType = RuleType.CloudRecordingRule; + } else { + // probably not the greatest assumption + ruleType = RuleType.CloudAlertRule; + } + + const evaluationDuration = rule.promRule?.evaluationTime; + const evaluationTimestamp = rule.promRule?.lastEvaluation; + + const copyRuleUID = useCallback(() => { + if (isGrafanaRulerRule(rule.rulerRule)) { + return rule.rulerRule.grafana_alert.uid; + } else { + return ''; + } + }, [rule.rulerRule]); + + const annotations: Annotations | undefined = !isRecordingRulerRule(rule.rulerRule) + ? rule.annotations ?? [] + : undefined; + + return ( + +
+ {/* type and identifier (optional) */} + + Rule type + {ruleType} + + + {isGrafanaRulerRule(rule.rulerRule) && ( + <> + Rule Identifier + + + {rule.rulerRule.grafana_alert.uid} + + + + + )} + + + {/* evaluation duration and pending period */} + + {evaluationDuration && ( + <> + Last evaluation + {evaluationTimestamp && evaluationDuration && ( + + {formatDistanceToNowStrict(new Date(evaluationTimestamp))} ago, took{' '} + {evaluationDuration}ms + + )} + + )} + + + {!isRecordingRulerRule(rule.rulerRule) && ( + <> + Pending period + {rule.rulerRule?.for ?? '0s'} + + )} + + + {/* nodata and execution error state mapping */} + {isGrafanaRulerRule(rule.rulerRule) && ( + <> + + Alert state if no data or all values are null + {rule.rulerRule.grafana_alert.no_data_state} + + + Alert state if execution error or timeout + {rule.rulerRule.grafana_alert.exec_err_state} + + + )} +
+ + {/* annotations go here */} + {annotations && ( + <> + Annotations + {Object.keys(annotations).length === 0 ? ( + + No annotations + + ) : ( +
+ {Object.entries(annotations).map(([name, value]) => ( + + {name} + + + ))} +
+ )} + + )} +
+ ); +}; + +interface AnnotationValueProps { + value: string; +} + +function AnnotationValue({ value }: AnnotationValueProps) { + const needsExternalLink = value && value.startsWith('http'); + const tokenizeValue = ; + + if (needsExternalLink) { + return ( + + {value} + + ); + } + + return {tokenizeValue}; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + metadataWrapper: css({ + display: 'grid', + gridTemplateColumns: 'auto auto', + rowGap: theme.spacing(3), + columnGap: theme.spacing(12), + }), +}); + +export { Details }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx index 7d805f13cb1..e2843dab278 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx @@ -1,5 +1,40 @@ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; -const History = () => <>History; +import { config } from '@grafana/runtime'; +import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { StateHistoryImplementation } from '../../../hooks/useStateHistoryModal'; + +const AnnotationsStateHistory = lazy(() => import('../../../components/rules/state-history/StateHistory')); +const LokiStateHistory = lazy(() => import('../../../components/rules/state-history/LokiStateHistory')); + +interface HistoryProps { + rule: RulerGrafanaRuleDTO; +} + +const History = ({ rule }: HistoryProps) => { + // can be "loki", "multiple" or "annotations" + const stateHistoryBackend = config.unifiedAlerting.alertStateHistoryBackend; + // can be "loki" or "annotations" + const stateHistoryPrimary = config.unifiedAlerting.alertStateHistoryPrimary; + + // if "loki" is either the backend or the primary, show the new state history implementation + const usingNewAlertStateHistory = [stateHistoryBackend, stateHistoryPrimary].some( + (implementation) => implementation === StateHistoryImplementation.Loki + ); + const implementation = usingNewAlertStateHistory + ? StateHistoryImplementation.Loki + : StateHistoryImplementation.Annotations; + + const ruleID = rule.grafana_alert.id ?? ''; + const ruleUID = rule.grafana_alert.uid; + + return ( + + {implementation === StateHistoryImplementation.Loki && } + {implementation === StateHistoryImplementation.Annotations && } + + ); +}; export { History }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx index 3504f6e28ca..3ad8da071f9 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx @@ -1,5 +1,20 @@ import React from 'react'; -const InstancesList = () => <>Instances; +import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; +import { CombinedRule } from 'app/types/unified-alerting'; + +import { RuleDetailsMatchingInstances } from '../../rules/RuleDetailsMatchingInstances'; + +interface Props { + rule: CombinedRule; +} + +const InstancesList = ({ rule }: Props) => ( + +); export { InstancesList }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx index 8ec527a5bec..6fa54b32874 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx @@ -1,5 +1,132 @@ -import React from 'react'; +import { produce } from 'immer'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useObservable } from 'react-use'; -const QueryResults = () => <>Query results; +import { LoadingState, PanelData, RelativeTimeRange } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Alert } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer'; +import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus'; +import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner'; +import { alertRuleToQueries } from '../../../utils/query'; +import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; + +interface Props { + rule: CombinedRule; +} + +const QueryResults = ({ rule }: Props) => { + const [evaluationTimeRanges, setEvaluationTimeRanges] = useState>({}); + + const runner = useMemo(() => new AlertingQueryRunner(), []); + const data = useObservable(runner.get()); + const loadingData = isLoading(data); + + const queries = useMemo(() => alertRuleToQueries(rule), [rule]); + + const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); + + const onRunQueries = useCallback(() => { + if (queries.length > 0 && allDataSourcesAvailable) { + const evalCustomizedQueries = queries.map((q) => ({ + ...q, + relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange, + })); + + let condition; + if (rule && isGrafanaRulerRule(rule.rulerRule)) { + condition = rule.rulerRule.grafana_alert.condition; + } + runner.run(evalCustomizedQueries, condition ?? 'A'); + } + }, [queries, allDataSourcesAvailable, rule, runner, evaluationTimeRanges]); + + useEffect(() => { + const alertQueries = alertRuleToQueries(rule); + const defaultEvalTimeRanges = Object.fromEntries( + alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }]) + ); + + setEvaluationTimeRanges(defaultEvalTimeRanges); + }, [rule]); + + useEffect(() => { + if (allDataSourcesAvailable) { + onRunQueries(); + } + }, [allDataSourcesAvailable, onRunQueries]); + + useEffect(() => { + return () => runner.destroy(); + }, [runner]); + + const onQueryTimeRangeChange = useCallback( + (refId: string, timeRange: RelativeTimeRange) => { + const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => { + draft[refId] = timeRange; + }); + setEvaluationTimeRanges(newEvalTimeRanges); + }, + [evaluationTimeRanges, setEvaluationTimeRanges] + ); + + const isFederatedRule = isFederatedRuleGroup(rule.group); + + return ( + <> + {loadingData ? ( + 'Loading...' + ) : ( + <> + {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( + + )} + + {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( +
+ {queries.map((query) => { + return ( + ds.uid === query.datasourceUid)} + queryData={data[query.refId]} + relativeTimeRange={query.relativeTimeRange} + evalTimeRange={evaluationTimeRanges[query.refId]} + onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} + isAlertCondition={false} + /> + ); + })} +
+ )} + {!isFederatedRule && !allDataSourcesAvailable && ( + + Cannot display the query preview. Some of the data sources used in the queries are not available. + + )} + + )} + + ); +}; + +function isLoading(data?: Record): boolean { + if (!data) { + return true; + } + + return !!Object.values(data).find((d) => d.state === LoadingState.Loading); +} export { QueryResults }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx new file mode 100644 index 00000000000..12739291681 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/DeleteModal.tsx @@ -0,0 +1,57 @@ +import React, { useState, useCallback, useMemo } from 'react'; + +import { ConfirmModal } from '@grafana/ui'; +import { dispatch } from 'app/store/store'; +import { CombinedRule } from 'app/types/unified-alerting'; + +import { deleteRuleAction } from '../../../state/actions'; +import { getRulesSourceName } from '../../../utils/datasource'; +import { fromRulerRule } from '../../../utils/rule-id'; + +type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; + +export const useDeleteModal = (): DeleteModalHook => { + const [ruleToDelete, setRuleToDelete] = useState(); + + const dismissModal = useCallback(() => { + setRuleToDelete(undefined); + }, []); + + const showModal = useCallback((rule: CombinedRule) => { + setRuleToDelete(rule); + }, []); + + const deleteRule = useCallback( + (ruleToDelete?: CombinedRule) => { + if (ruleToDelete && ruleToDelete.rulerRule) { + const identifier = fromRulerRule( + getRulesSourceName(ruleToDelete.namespace.rulesSource), + ruleToDelete.namespace.name, + ruleToDelete.group.name, + ruleToDelete.rulerRule + ); + + dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' })); + dismissModal(); + } + }, + [dismissModal] + ); + + const modal = useMemo( + () => ( + deleteRule(ruleToDelete)} + onDismiss={dismissModal} + /> + ), + [deleteRule, dismissModal, ruleToDelete] + ); + + return [modal, showModal, dismissModal]; +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx index 52c29a2367f..43bac4335af 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx @@ -1,97 +1,147 @@ -import React, { useMemo, useState } from 'react'; +import { isEmpty, truncate } from 'lodash'; +import React from 'react'; -import { Alert, Button, Icon, LoadingPlaceholder, Tab, TabContent, TabsBar, Text, Stack } from '@grafana/ui'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { GrafanaAlertState } from 'app/types/unified-alerting-dto'; +import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data'; +import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui'; +import { PageInfoItem } from 'app/core/components/Page/types'; +import { appEvents } from 'app/core/core'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; -import { useRuleViewerPageTitle } from '../../../hooks/alert-details/useRuleViewerPageTitle'; -import { useCombinedRule } from '../../../hooks/useCombinedRule'; +import { defaultPageNav } from '../../../RuleViewer'; +import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities'; +import { Annotation } from '../../../utils/constants'; +import { + createShareLink, + isLocalDevEnv, + isOpenSourceEdition, + makeDashboardLink, + makePanelLink, + makeRuleBasedSilenceLink, +} from '../../../utils/misc'; import * as ruleId from '../../../utils/rule-id'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; +import { createUrl } from '../../../utils/url'; +import { AlertLabels } from '../../AlertLabels'; import { AlertStateDot } from '../../AlertStateDot'; +import { AlertingPageWrapper } from '../../AlertingPageWrapper'; +import MoreButton from '../../MoreButton'; import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; -import { Spacer } from '../../Spacer'; +import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton'; +import { Details } from '../tabs/Details'; import { History } from '../tabs/History'; import { InstancesList } from '../tabs/Instances'; import { QueryResults } from '../tabs/Query'; import { Routing } from '../tabs/Routing'; -type RuleViewerProps = GrafanaRouteComponentProps<{ - id: string; - sourceName: string; -}>; +import { useDeleteModal } from './DeleteModal'; -enum Tabs { - Instances, - Query, - Routing, - History, +type RuleViewerProps = { + rule: CombinedRule; + identifier: RuleIdentifier; +}; + +enum ActiveTab { + Query = 'query', + Instances = 'instances', + History = 'history', + Routing = 'routing', + Details = 'details', } -// @TODO -// hook up tabs to query params or path segment -// figure out why we needed -// add provisioning and federation stuff back in -const RuleViewer = ({ match }: RuleViewerProps) => { - const [activeTab, setActiveTab] = useState(Tabs.Instances); +const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { + const { pageNav, activeTab } = usePageNav(rule); + const [deleteModal, showDeleteModal] = useDeleteModal(); - const id = ruleId.getRuleIdFromPathname(match.params); - const identifier = useMemo(() => { - if (!id) { - throw new Error('Rule ID is required'); + const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); + const canEdit = editSupported && editAllowed; + + const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); + const canDelete = deleteSupported && deleteAllowed; + + const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); + const canDuplicate = duplicateSupported && duplicateAllowed; + + const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); + const canSilence = silenceSupported && silenceAllowed; + + const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport); + const canExport = exportSupported && exportAllowed; + + const promRule = rule.promRule; + + const isAlertType = isAlertingRule(promRule); + + const isFederatedRule = isFederatedRuleGroup(rule.group); + const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + + /** + * Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana. + * We should show it in development mode + */ + const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv(); + const shareUrl = createShareLink(rule.namespace.rulesSource, rule); + + const copyShareUrl = () => { + if (navigator.clipboard) { + navigator.clipboard.writeText(shareUrl); + appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']); } + }; - return ruleId.parse(id, true); - }, [id]); - - const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); - - // we're setting the document title and the breadcrumb manually - useRuleViewerPageTitle(rule); - - if (loading) { - return ; - } - - if (error) { - return String(error); - } - - if (rule) { - const summary = rule.annotations['summary']; - const promRule = rule.promRule; - - const isAlertType = isAlertingRule(promRule); - const numberOfInstance = isAlertType ? promRule.alerts?.length : undefined; - - const isFederatedRule = isFederatedRuleGroup(rule.group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - - return ( - <> - - {/* breadcrumb and actions */} - - - - - - - - - {/* header */} - - - - </Stack> - {summary && <Summary text={summary} />} - </Stack> + return ( + <AlertingPageWrapper + pageNav={pageNav} + navId="alert-list" + isLoading={false} + renderTitle={(title) => { + return <Title name={title} state={isAlertType ? promRule.state : undefined} />; + }} + actions={[ + canEdit && <EditButton key="edit-action" identifier={identifier} />, + <Dropdown + key="more-actions" + overlay={ + <Menu> + {canSilence && ( + <Menu.Item + label="Silence" + icon="bell-slash" + url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)} + /> + )} + {shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />} + {canDuplicate && <Menu.Item label="Duplicate" icon="copy" />} + <Menu.Divider /> + <Menu.Item label="Copy link" icon="share-alt" onClick={copyShareUrl} /> + {canExport && ( + <Menu.Item + label="Export" + icon="download-alt" + childItems={[ + <Menu.Item key="no-modifications" label="Without modifications" icon="file-blank" />, + <Menu.Item key="with-modifications" label="With modifications" icon="file-alt" />, + ]} + /> + )} + {canDelete && ( + <> + <Menu.Divider /> + <Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => showDeleteModal(rule)} /> + </> + )} + </Menu> + } + > + <MoreButton size="md" /> + </Dropdown>, + ]} + info={createMetadata(rule)} + > + <Stack direction="column" gap={2}> + {/* actions */} + <Stack direction="column" gap={2}> {/* alerts and notifications and stuff */} {isFederatedRule && ( <Alert severity="info" title="This rule is part of a federated rule group."> @@ -107,74 +157,237 @@ const RuleViewer = ({ match }: RuleViewerProps) => { )} {isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />} {/* tabs and tab content */} - <TabsBar> - <Tab label="Instances" active counter={numberOfInstance} onChangeTab={() => setActiveTab(Tabs.Instances)} /> - <Tab label="Query" onChangeTab={() => setActiveTab(Tabs.Query)} /> - <Tab label="Routing" onChangeTab={() => setActiveTab(Tabs.Routing)} /> - <Tab label="History" onChangeTab={() => setActiveTab(Tabs.History)} /> - </TabsBar> <TabContent> - {activeTab === Tabs.Instances && <InstancesList />} - {activeTab === Tabs.Query && <QueryResults />} - {activeTab === Tabs.Routing && <Routing />} - {activeTab === Tabs.History && <History />} + {activeTab === ActiveTab.Query && <QueryResults rule={rule} />} + {activeTab === ActiveTab.Instances && <InstancesList rule={rule} />} + {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && <History rule={rule.rulerRule} />} + {activeTab === ActiveTab.Routing && <Routing />} + {activeTab === ActiveTab.Details && <Details rule={rule} />} </TabContent> </Stack> - </> - ); - } - - return null; + </Stack> + {deleteModal} + </AlertingPageWrapper> + ); }; -interface BreadcrumbProps { - folder: string; - evaluationGroup: string; +interface EditButtonProps { + identifier: RuleIdentifier; } -const BreadCrumb = ({ folder, evaluationGroup }: BreadcrumbProps) => ( - <Stack alignItems="center" gap={0.5}> - <Text color="secondary"> - <Icon name="folder" /> - </Text> - <Text variant="body" color="primary"> - {folder} - </Text> - <Text variant="body" color="secondary"> - <Icon name="angle-right" /> - </Text> - <Text variant="body" color="primary"> - {evaluationGroup} - </Text> - </Stack> -); +export const EditButton = ({ identifier }: EditButtonProps) => { + const returnTo = location.pathname + location.search; + const ruleIdentifier = ruleId.stringifyIdentifier(identifier); + const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo }); + + return ( + <LinkButton variant="secondary" icon="pen" href={editURL}> + Edit + </LinkButton> + ); +}; + +const createMetadata = (rule: CombinedRule): PageInfoItem[] => { + const { labels, annotations, group } = rule; + const metadata: PageInfoItem[] = []; + + const runbookUrl = annotations[Annotation.runbookURL]; + const dashboardUID = annotations[Annotation.dashboardUID]; + const panelID = annotations[Annotation.panelID]; + + const hasPanel = dashboardUID && panelID; + const hasDashboardWithoutPanel = dashboardUID && !panelID; + const hasLabels = !isEmpty(labels); + + const interval = group.interval; + + if (runbookUrl) { + metadata.push({ + label: 'Runbook', + value: ( + <TextLink variant="bodySmall" href={runbookUrl} external> + {/* TODO instead of truncating the string, we should use flex and text overflow properly to allow it to take up all of the horizontal space available */} + {truncate(runbookUrl, { length: 42 })} + </TextLink> + ), + }); + } + + if (hasPanel) { + metadata.push({ + label: 'Dashboard and panel', + value: ( + <TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)} external> + View panel + </TextLink> + ), + }); + } else if (hasDashboardWithoutPanel) { + metadata.push({ + label: 'Dashboard', + value: ( + <TextLink variant="bodySmall" href={makeDashboardLink(dashboardUID)} external> + View dashboard + </TextLink> + ), + }); + } + + if (interval) { + metadata.push({ + label: 'Evaluation interval', + value: <Text color="primary">Every {interval}</Text>, + }); + } + + if (hasLabels) { + metadata.push({ + label: 'Labels', + /* TODO truncate number of labels, maybe build in to component? */ + value: <AlertLabels labels={labels} size="sm" />, + }); + } + + return metadata; +}; + +// TODO move somewhere else +export const createListFilterLink = (values: Array<[string, string]>) => { + const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]); + return createUrl(`/alerting/list?` + params.toString()); +}; interface TitleProps { name: string; - state: GrafanaAlertState; + // recording rules don't have a state + state?: PromAlertingRuleState; } -const Title = ({ name, state }: TitleProps) => ( - <header> - <Stack alignItems={'center'} gap={1}> +export const Title = ({ name, state }: TitleProps) => ( + <div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: '100%' }}> + <LinkButton variant="secondary" icon="angle-left" href="/alerting/list" /> + <Text element="h1" truncate> + {name} + </Text> + {/* recording rules won't have a state */} + {state && <StateBadge state={state} />} + </div> +); + +interface StateBadgeProps { + state: PromAlertingRuleState; +} + +// TODO move to separate component +const StateBadge = ({ state }: StateBadgeProps) => { + let stateLabel: string; + let textColor: 'success' | 'error' | 'warning'; + + switch (state) { + case PromAlertingRuleState.Inactive: + textColor = 'success'; + stateLabel = 'Normal'; + break; + case PromAlertingRuleState.Firing: + textColor = 'error'; + stateLabel = 'Firing'; + break; + case PromAlertingRuleState.Pending: + textColor = 'warning'; + stateLabel = 'Pending'; + break; + } + + return ( + <Stack direction="row" gap={0.5}> <AlertStateDot size="md" state={state} /> - {/* <Button variant="secondary" fill="outline" icon="angle-left" /> */} - <Text element="h1" variant="h2" weight="bold"> - {name} + <Text variant="bodySmall" color={textColor}> + {stateLabel} </Text> - {/* <Badge color="red" text={state} icon="exclamation-circle" /> */} </Stack> - </header> -); + ); +}; -interface SummaryProps { - text: string; +function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] { + const [queryParams, setQueryParams] = useQueryParams(); + const tabFromQuery = queryParams['tab']; + + const activeTab = isValidTab(tabFromQuery) ? tabFromQuery : ActiveTab.Query; + + const setActiveTab = (tab: ActiveTab) => { + setQueryParams({ tab }); + }; + + return [activeTab, setActiveTab]; } -const Summary = ({ text }: SummaryProps) => ( - <Text variant="body" color="secondary"> - {text} - </Text> -); +function isValidTab(tab: UrlQueryValue): tab is ActiveTab { + const isString = typeof tab === 'string'; + // @ts-ignore + return isString && Object.values(ActiveTab).includes(tab); +} + +function usePageNav(rule: CombinedRule) { + const [activeTab, setActiveTab] = useActiveTab(); + + const { annotations, promRule } = rule; + + const summary = annotations[Annotation.summary]; + const isAlertType = isAlertingRule(promRule); + const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined; + + const pageNav: NavModelItem = { + ...defaultPageNav, + text: rule.name, + subTitle: summary, + children: [ + { + text: 'Query and conditions', + active: activeTab === ActiveTab.Query, + onClick: () => { + setActiveTab(ActiveTab.Query); + }, + }, + { + text: 'Instances', + active: activeTab === ActiveTab.Instances, + onClick: () => { + setActiveTab(ActiveTab.Instances); + }, + tabCounter: numberOfInstance, + }, + { + text: 'History', + active: activeTab === ActiveTab.History, + onClick: () => { + setActiveTab(ActiveTab.History); + }, + }, + { + text: 'Details', + active: activeTab === ActiveTab.Details, + onClick: () => { + setActiveTab(ActiveTab.Details); + }, + }, + ], + parentItem: { + text: rule.group.name, + url: createListFilterLink([ + ['namespace', rule.namespace.name], + ['group', rule.group.name], + ]), + parentItem: { + text: rule.namespace.name, + url: createListFilterLink([['namespace', rule.namespace.name]]), + }, + }, + }; + + return { + pageNav, + activeTab, + }; +} export default RuleViewer; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index c38efc5bd45..070622f132c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -53,7 +53,9 @@ export const RuleDetails = ({ rule }: Props) => { <RuleDetailsDataSources rulesSource={rulesSource} rule={rule} /> </div> </div> - <RuleDetailsMatchingInstances rule={rule} itemsDisplayLimit={INSTANCES_DISPLAY_LIMIT} /> + <DetailsField label="Matching instances" horizontal={true}> + <RuleDetailsMatchingInstances rule={rule} itemsDisplayLimit={INSTANCES_DISPLAY_LIMIT} /> + </DetailsField> </div> ); }; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 1f5c37798fc..0a24cff571b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -37,7 +37,7 @@ import { import * as ruleId from '../../utils/rule-id'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { createUrl } from '../../utils/url'; -import { DeclareIncident } from '../bridges/DeclareIncidentButton'; +import { DeclareIncidentButton } from '../bridges/DeclareIncidentButton'; import { RedirectToCloneRule } from './CloneRule'; @@ -196,7 +196,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop if (isFiringRule && shouldShowDeclareIncidentButton()) { buttons.push( <Fragment key="declare-incident"> - <DeclareIncident title={rule.name} url={buildShareUrl()} /> + <DeclareIncidentButton title={rule.name} url={buildShareUrl()} /> </Fragment> ); } @@ -318,7 +318,8 @@ function shouldShowDeclareIncidentButton() { export const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` - padding: ${theme.spacing(2)} 0; + padding: 0 0 ${theme.spacing(2)} 0; + gap: ${theme.spacing(1)}; display: flex; flex-direction: row; justify-content: space-between; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index 042b710db2b..b2af9f91508 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -18,7 +18,6 @@ import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../../utils/datasource'; import { isAlertingRule } from '../../utils/rules'; -import { DetailsField } from '../DetailsField'; import { AlertInstancesTable } from './AlertInstancesTable'; import { getComponentsFromStats } from './RuleStats'; @@ -83,7 +82,14 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { // Count All By State is used only when filtering is enabled and we have access to all instances const countAllByState = countBy(promRule.alerts, (alert) => mapStateWithReasonToBaseState(alert.state)); - const totalInstancesCount = sum(Object.values(instanceTotals)); + + // error state is not a separate state + const totalInstancesCount = sum([ + instanceTotals.alerting, + instanceTotals.inactive, + instanceTotals.pending, + instanceTotals.nodata, + ]); const hiddenInstancesCount = totalInstancesCount - visibleInstances.length; const stats: ShowMoreStats = { @@ -104,7 +110,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { ) : undefined; return ( - <DetailsField label="Matching instances" horizontal={true}> + <> {enableFiltering && ( <div className={cx(styles.flexRow, styles.spaceBetween)}> <div className={styles.flexRow}> @@ -126,7 +132,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { )} {!enableFiltering && <div className={styles.stats}>{statsComponents}</div>} <AlertInstancesTable rule={rule} instances={visibleInstances} pagination={pagination} footerRow={footerRow} /> - </DetailsField> + </> ); } 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 30c476bf8d5..dd1a16b6908 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 @@ -1,13 +1,14 @@ import { css } from '@emotion/css'; -import { isEmpty, sortBy, take, uniq } from 'lodash'; +import { fromPairs, isEmpty, sortBy, take, uniq } from 'lodash'; 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, TagList, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { Alert, Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui'; import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { combineMatcherStrings } from '../../../utils/alertmanager'; +import { AlertLabels } from '../../AlertLabels'; import { HoverCard } from '../../HoverCard'; import { LogRecordViewerByTimestamp } from './LogRecordViewer'; @@ -119,8 +120,8 @@ const LokiStateHistory = ({ ruleUID }: Props) => { <Tooltip content="Common labels are the ones attached to all of the alert instances"> <Icon name="info-circle" /> </Tooltip> + <AlertLabels labels={fromPairs(commonLabels)} size="sm" /> </Stack> - <TagList tags={commonLabels.map((label) => label.join('='))} /> </div> )} {isEmpty(frameSubset) ? ( diff --git a/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx b/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx index 816722aa860..2162bd7c9a1 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx @@ -67,7 +67,9 @@ const StateHistory = ({ alertId }: Props) => { return ( <div key={groupKey}> <header className={styles.tableGroupKey}> - <code>{groupKey}</code> + <code className={styles.goupKeyText} aria-label={groupKey}> + {groupKey} + </code> </header> <DynamicTable cols={columns} items={tableItems} pagination={{ itemsPerPage: 25 }} /> </div> @@ -198,6 +200,12 @@ const getStyles = (theme: GrafanaTheme2) => ({ margin-top: ${theme.spacing(2)}; margin-bottom: ${theme.spacing(2)}; `, + goupKeyText: css({ + overflowX: 'auto', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'block', + }), }); export default StateHistory; diff --git a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx index 2d08ec52875..078dbca0f27 100644 --- a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -9,7 +9,7 @@ import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; const AnnotationsStateHistory = lazy(() => import('../components/rules/state-history/StateHistory')); const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory')); -enum StateHistoryImplementation { +export enum StateHistoryImplementation { Loki = 'loki', Annotations = 'annotations', }