From 488eafe02e26778c9503bc664db57e808cc54f6d Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 7 Oct 2025 13:39:13 +0200 Subject: [PATCH] Alerting: Triage (#110339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP column layout * add columns on top of splitter * small layout fixes * WIP grouped items * WIP * WIP * WIP * small refactoring * some more WIP * WIP * refactoring some functions * use "unknown" state intstead of assuming "normal" state * Add groupBy filter * Add label-based filter * Add basic displaying of grouped data * lint * Fix timeline alertstate selection * Add AlertInstanceScene for displaying instances * Add WorkbenchContext to pass column width and domain deeper into the tree * Update single rule query, merge pending and firing metrics into one instance * use area chart for summary * Add not working rule summary * step interpolation for summary chart * Add rule state chart * Update panel settings for alert rule summary * Reactify SummaryChart component * WIP react version * Reactify AlertRuleDetails component * Reactify AlertRuleSummary component * refactor summary chart a bit * clean up – Reactify * set min y-scale for rule summary * remove macro code * small fixes * remove line width for summary * attempt to make the instances a native chart * native chart for instances part 2 * sync cursor crosshair * extract instance row into separate component * Add minHeight for rows without labels * Add Summary component * Move scrolling to rows container * Add lazy rendering of rows * Use default page size * minor layout tweaks * typescript fixes * simplify the grouping / data frame processing * big cleanup of code * split up rows components * further split row components * moving files around * use text summary stats * link to alert rule * various eslint and typescript fixes * do not compute common labels for single series * small UI updates * add depth support and colored labels * simplify with props type * Reuse Workbenck query to populate alert rule summaries * add custom folder row component and set as default * small UI tweaks * remove unused sticky * Hide triage page behind a feature toggle * Add loading states to workbench and alert rows * Update translations * ✨ * Fix lint errors * Fix EditorColumnHeader rendering, remove unused code * Update translations * Move EditorColumnHeader to shared components directory * add type string for union discrimination of row --------- Co-authored-by: Konrad Lalik --- public/app/features/alerting/routes.tsx | 5 +- .../unified/components/EditorColumnHeader.tsx | 59 +++++ .../templates/EditorColumnHeader.tsx | 37 --- .../components/receivers/PayloadEditor.tsx | 2 +- .../components/receivers/TemplateForm.tsx | 2 +- .../components/receivers/TemplatePreview.tsx | 2 +- .../form/fields/TemplateContentAndPreview.tsx | 2 +- .../alerting/unified/triage/Timeline.tsx | 37 +++ .../alerting/unified/triage/Triage.md | 19 ++ .../alerting/unified/triage/Triage.tsx | 25 +++ .../alerting/unified/triage/Workbench.tsx | 211 ++++++++++++++++++ .../unified/triage/WorkbenchContext.tsx | 34 +++ .../alerting/unified/triage/constants.ts | 10 + .../unified/triage/rows/AlertRuleRow.tsx | 55 +++++ .../unified/triage/rows/FolderGroupRow.tsx | 47 ++++ .../unified/triage/rows/GenericRow.tsx | 132 +++++++++++ .../alerting/unified/triage/rows/GroupRow.tsx | 42 ++++ .../unified/triage/rows/InstanceRow.tsx | 114 ++++++++++ .../alerting/unified/triage/rows/utils.ts | 13 ++ .../triage/scene/AlertRuleInstances.tsx | 113 ++++++++++ .../unified/triage/scene/AlertRuleSummary.tsx | 93 ++++++++ .../unified/triage/scene/SummaryChart.tsx | 54 +++++ .../unified/triage/scene/SummaryStats.tsx | 65 ++++++ .../unified/triage/scene/TriageScene.tsx | 69 ++++++ .../unified/triage/scene/Workbench.tsx | 133 +++++++++++ .../alerting/unified/triage/scene/utils.ts | 54 +++++ .../features/alerting/unified/triage/types.ts | 24 ++ public/locales/en-US/grafana.json | 19 ++ 28 files changed, 1429 insertions(+), 43 deletions(-) create mode 100644 public/app/features/alerting/unified/components/EditorColumnHeader.tsx delete mode 100644 public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx create mode 100644 public/app/features/alerting/unified/triage/Timeline.tsx create mode 100644 public/app/features/alerting/unified/triage/Triage.md create mode 100644 public/app/features/alerting/unified/triage/Triage.tsx create mode 100644 public/app/features/alerting/unified/triage/Workbench.tsx create mode 100644 public/app/features/alerting/unified/triage/WorkbenchContext.tsx create mode 100644 public/app/features/alerting/unified/triage/constants.ts create mode 100644 public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx create mode 100644 public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx create mode 100644 public/app/features/alerting/unified/triage/rows/GenericRow.tsx create mode 100644 public/app/features/alerting/unified/triage/rows/GroupRow.tsx create mode 100644 public/app/features/alerting/unified/triage/rows/InstanceRow.tsx create mode 100644 public/app/features/alerting/unified/triage/rows/utils.ts create mode 100644 public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/SummaryChart.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/SummaryStats.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/TriageScene.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/Workbench.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/utils.ts create mode 100644 public/app/features/alerting/unified/triage/types.ts diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index b7c025868a3..da4c513388a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom-v5-compat'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; -import { AlertingPageWrapper } from 'app/features/alerting/unified/components/AlertingPageWrapper'; import { AccessControlAction } from 'app/types/accessControl'; import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions'; @@ -338,7 +337,9 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { routes.push({ path: '/alerting/triage', roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), - component: () => , + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingTriage" */ 'app/features/alerting/unified/triage/Triage') + ), }); } diff --git a/public/app/features/alerting/unified/components/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx new file mode 100644 index 00000000000..f7c4113bab2 --- /dev/null +++ b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx @@ -0,0 +1,59 @@ +import { css } from '@emotion/css'; +import * as React from 'react'; +import { type MergeExclusive } from 'type-fest'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Label, Stack, useStyles2 } from '@grafana/ui'; + +interface BaseProps { + id?: string; +} + +interface ChildrenProps extends BaseProps { + children: React.ReactNode; +} + +interface LabelActionsProps extends BaseProps { + label: string; + actions?: React.ReactNode; +} + +type Props = MergeExclusive; + +export function EditorColumnHeader({ label, actions, id, children }: Props) { + const styles = useStyles2(editorColumnStyles); + + if (children) { + return
{children}
; + } + + return ( +
+ + {actions && ( + + {actions} + + )} +
+ ); +} + +const editorColumnStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(1, 2), + backgroundColor: theme.colors.background.secondary, + border: `1px solid ${theme.colors.border.medium}`, + borderTopLeftRadius: theme.shape.radius.default, + borderTopRightRadius: theme.shape.radius.default, + }), + label: css({ + margin: 0, + }), +}); diff --git a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx deleted file mode 100644 index bd0d77a2a15..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { css } from '@emotion/css'; -import * as React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Label, Stack, useStyles2 } from '@grafana/ui'; - -type Props = { label: string; actions?: React.ReactNode; id?: string }; - -export function EditorColumnHeader({ label, actions, id }: Props) { - const styles = useStyles2(editorColumnStyles); - - return ( -
- - - {actions} - -
- ); -} - -const editorColumnStyles = (theme: GrafanaTheme2) => ({ - container: css({ - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: theme.spacing(1, 2), - backgroundColor: theme.colors.background.secondary, - borderBottom: `1px solid ${theme.colors.border.medium}`, - }), - label: css({ - margin: 0, - }), -}); diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx index a93285844d1..34ccbc5715c 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -8,7 +8,7 @@ import { Trans, t } from '@grafana/i18n'; import { Button, CodeEditor, Dropdown, Menu, Stack, Toggletip, useStyles2 } from '@grafana/ui'; import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { AlertInstanceModalSelector } from './AlertInstanceModalSelector'; import { AlertTemplatePreviewData } from './TemplateData'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index aa5c347464a..bb7708a5226 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -32,9 +32,9 @@ import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; import { Spacer } from '../Spacer'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; import { NotificationTemplate, useCreateNotificationTemplate, diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index fc6014a0e0e..5e5ecc1e08b 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -10,7 +10,7 @@ import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui'; import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi'; import { AIFeedbackButtonComponent } from '../../enterprise-components/AI/addAIFeedbackButton'; import { stringifyErrorLike } from '../../utils/misc'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { usePreviewTemplate } from './usePreviewTemplate'; diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx index 075a58b4a95..7a5c016a126 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx @@ -8,7 +8,7 @@ import { Box, useStyles2 } from '@grafana/ui'; import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../../../EditorColumnHeader'; import { TemplateEditor } from '../../TemplateEditor'; import { TemplatePreview } from '../../TemplatePreview'; diff --git a/public/app/features/alerting/unified/triage/Timeline.tsx b/public/app/features/alerting/unified/triage/Timeline.tsx new file mode 100644 index 00000000000..3bde44c91b9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Timeline.tsx @@ -0,0 +1,37 @@ +import { scaleTime } from 'd3-scale'; +import { useMemo } from 'react'; +import { useMeasure } from 'react-use'; + +import { Stack, Text } from '@grafana/ui'; + +import { Domain } from './types'; + +interface TimelineProps { + domain: Domain; +} + +export const TimelineHeader = ({ domain }: TimelineProps) => { + const [ref, { width }] = useMeasure(); + + const ticks = useMemo(() => { + const xScale = scaleTime().domain(domain).range([0, width]).nice(0); + const tickFormatter = xScale.tickFormat(); + + return xScale.ticks(5).map((value) => ({ + value: tickFormatter(value), + xOffset: xScale(value), + })); + }, [domain, width]); + + return ( +
+ + {ticks.map((tick) => ( + + {tick.value} + + ))} + +
+ ); +}; diff --git a/public/app/features/alerting/unified/triage/Triage.md b/public/app/features/alerting/unified/triage/Triage.md new file mode 100644 index 00000000000..3bb802e91ab --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.md @@ -0,0 +1,19 @@ +# Triage view + +The triage view should serve several purposes and be a central place for users to manage their alert instances. + +## Goals + +- Observe the current state of their system +- Help correlate alerts with each other +- Be a launchpad for further investigation + +## Non-goals + +- Managing alert rules + +## Technical goals + +- Build re-usable components that can be used in other parts of Grafana and plugins +- These should be a mix of presentation components and data components +- Eventually most of this should live in the Grafana Alerting package diff --git a/public/app/features/alerting/unified/triage/Triage.tsx b/public/app/features/alerting/unified/triage/Triage.tsx new file mode 100644 index 00000000000..284be52fa41 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.tsx @@ -0,0 +1,25 @@ +import { t } from '@grafana/i18n'; +import { UrlSyncContextProvider } from '@grafana/scenes'; +import { withErrorBoundary } from '@grafana/ui'; + +import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; + +import { TriageScene, triageScene } from './scene/TriageScene'; + +export const TriagePage = () => { + return ( + + + + + + ); +}; + +export default withErrorBoundary(TriagePage); diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx new file mode 100644 index 00000000000..98c07cc8de6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -0,0 +1,211 @@ +import { css, cx } from '@emotion/css'; +import { take } from 'lodash'; +import { useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { SceneQueryRunner } from '@grafana/scenes'; +import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui'; +import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; + +import { EditorColumnHeader } from '../components/EditorColumnHeader'; +import LoadMoreHelper from '../rule-list/LoadMoreHelper'; + +import { TimelineHeader } from './Timeline'; +import { WorkbenchProvider } from './WorkbenchContext'; +import { AlertRuleRow } from './rows/AlertRuleRow'; +import { FolderGroupRow } from './rows/FolderGroupRow'; +import { GroupRow } from './rows/GroupRow'; +import { generateRowKey } from './rows/utils'; +import { GenericRowSkeleton } from './scene/AlertRuleInstances'; +import { SummaryChartReact } from './scene/SummaryChart'; +import { SummaryStatsReact } from './scene/SummaryStats'; +import { Domain, Filter, WorkbenchRow } from './types'; + +type WorkbenchProps = { + domain: Domain; + data: WorkbenchRow[]; + groupBy?: string[]; // @TODO proper type + filterBy?: Filter[]; + queryRunner: SceneQueryRunner; +}; + +const initialSize = 1 / 3; + +// Helper function to recursively render WorkbenchRow items with children pattern +function renderWorkbenchRow( + row: WorkbenchRow, + leftColumnWidth: number, + domain: Domain, + key: React.Key, + depth = 0 +): React.ReactElement { + if (row.type === 'alertRule') { + return ; + } else { + const children = row.rows.map((childRow, childIndex) => + renderWorkbenchRow(childRow, leftColumnWidth, domain, `${key}-${generateRowKey(childRow, childIndex)}`, depth + 1) + ); + + // Check if this is a grafana_folder group and use FolderGroupRow + if (row.metadata.label === 'grafana_folder') { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + } +} + +/** + * The workbench displays groups of alerts, each group containing metadata and a chart. + * Alerts can be arbitrarily grouped by any number of labels. By default all instances are grouped by alertname. + * + * The page consist of a left column with metadata for the row and a right column with charts. + * Below is a rough layout of the page: + * + * The page is divided into two columns, the size of these columns is determined by the splitter. + * There is a useMeasure hook to measure the size of the left column, which is used to set the width of the group items. + * We do this because each row needs to be a flex container such that if the height of the left colorn changes, the + * right column will also change its height accordingly. This would not be possible if we used a simplified column layout. + * + * This also means we draw the rows _on top_ of the splitter, in other words the contents of the splitter are empty + * and we only use it to determine the width of the left column of the rows that are overlayed on top. + * + * Each group is a row with a left and a right column. Each row consists of two cells (the left and the right cell). + * The left cell contains the metadata for the group, the right cell contains the chart. + ┌─────────────────────────┐ ┌───────────────────────────────────┐ + │┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│ + │ │ + ││ Row ││ + │ │ + │└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│ + │┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│ + │ ┌──────────────────────┐ ┌───────────────────────────────┐ │ + │││ Cell │ │ Cell │││ + │ └──────────────────────┘ └───────────────────────────────┘ │ + │└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│ + │ │ │ │ + │ │││ │ + │ │││ │ + │ │││ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + └─────────────────────────┘ └───────────────────────────────────┘ + */ +export function Workbench({ domain, data, queryRunner }: WorkbenchProps) { + const styles = useStyles2(getStyles); + + const isLoading = !queryRunner.isDataReadyToDisplay(); + const [pageIndex, setPageIndex] = useState(1); + // splitter for template and payload editor + const splitter = useSplitter({ + direction: 'row', + // if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor + initialSize: initialSize, + dragPosition: 'middle', + }); + + // this will measure the size of the left most column of the splitter, so we can use it to set the width of the group items + const [ref, rect] = useMeasure(); + const leftColumnWidth = rect.width; + + const itemsToRender = pageIndex * DEFAULT_PER_PAGE_PAGINATION; + const dataSlice = take(data, itemsToRender); + const hasMore = data.length > itemsToRender; + + return ( +
+ {/* dummy splitter to handle flex width of group items */} +
+
+
+
+
+
+
+
+
+ {/* content goes here */} +
+
+ + +
+
+ + + + +
+ {/* Render actual data */} +
+ + + {isLoading ? ( + <> + + + + + ) : ( + dataSlice.map((row, index) => { + const rowKey = generateRowKey(row, index); + return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey); + }) + )} + {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} + + +
+
+
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + const summaryHeight = 200; + return { + groupsContainer: css({ + position: 'absolute', + width: '100%', + height: '100%', + + display: 'flex', + flexDirection: 'column', + }), + groupItemWrapper: (width: number) => + css({ + display: 'grid', + gridTemplateColumns: `${width}px auto`, + gap: theme.spacing(2), + }), + virtualizedContainer: css({ + display: 'flex', + flex: 1, + overflow: 'hidden', // Let AutoSizer handle the overflow + }), + summaryContainer: css({ + gridTemplateRows: summaryHeight, + marginBottom: theme.spacing(2), + }), + headerContainer: css({ + top: summaryHeight, + }), + flexFull: css({ + flex: 1, + }), + minColumnWidth: css({ + minWidth: 300, + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/WorkbenchContext.tsx b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx new file mode 100644 index 00000000000..620455d9af6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx @@ -0,0 +1,34 @@ +import React, { createContext, useContext } from 'react'; + +import { SceneQueryRunner } from '@grafana/scenes'; + +import { Domain } from './types'; + +interface WorkbenchContextValue { + leftColumnWidth: number; + domain: Domain; + queryRunner: SceneQueryRunner; +} + +const WorkbenchContext = createContext(undefined); + +export function useWorkbenchContext(): WorkbenchContextValue { + const context = useContext(WorkbenchContext); + if (!context) { + throw new Error('useWorkbenchContext must be used within a WorkbenchProvider'); + } + return context; +} + +interface WorkbenchProviderProps { + leftColumnWidth: number; + domain: Domain; + queryRunner: SceneQueryRunner; + children: React.ReactNode; +} + +export function WorkbenchProvider({ leftColumnWidth, domain, queryRunner, children }: WorkbenchProviderProps) { + return ( + {children} + ); +} diff --git a/public/app/features/alerting/unified/triage/constants.ts b/public/app/features/alerting/unified/triage/constants.ts new file mode 100644 index 00000000000..627ef4ebb7a --- /dev/null +++ b/public/app/features/alerting/unified/triage/constants.ts @@ -0,0 +1,10 @@ +import { config } from '@grafana/runtime'; + +export const VARIABLES = { + groupBy: 'groupBy', + filters: 'filters', +}; + +export const DATASOURCE_UID = config.unifiedAlerting.stateHistory?.prometheusTargetDatasourceUID; +export const METRIC_NAME = config.unifiedAlerting.stateHistory?.prometheusMetricName ?? 'GRAFANA_ALERTS'; +export const DEFAULT_FIELDS = ['alertname', 'grafana_folder', 'grafana_rule_uid', 'alertstate'] as const; diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx new file mode 100644 index 00000000000..fefd37258f9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Stack, Text, TextLink } from '@grafana/ui'; + +import { MetaText } from '../../components/MetaText'; +import { WithReturnButton } from '../../components/WithReturnButton'; +import { rulesNav } from '../../utils/navigation'; +import { AlertRuleInstances } from '../scene/AlertRuleInstances'; +import { AlertRuleSummary } from '../scene/AlertRuleSummary'; +import { AlertRuleRow as AlertRuleRowType } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface AlertRuleRowProps { + row: AlertRuleRowType; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; +} + +export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => { + return ( + + {row.metadata.title} + + } + /> + } + metadata={ + + + + {row.metadata.folder} + + + } + content={} + depth={depth} + > + + + ); +}; diff --git a/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx new file mode 100644 index 00000000000..4809a44573a --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx @@ -0,0 +1,47 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Stack, Text, useStyles2 } from '@grafana/ui'; + +import { MetaText } from '../../components/MetaText'; +import { GenericGroupedRow } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface FolderGroupRowProps { + row: GenericGroupedRow; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; + children?: React.ReactNode; +} + +export const FolderGroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: FolderGroupRowProps) => { + const styles = useStyles2(getStyles); + + return ( + + + {row.metadata.value} + + } + isOpenByDefault={true} + leftColumnClassName={styles.folderGroupRow} + rightColumnClassName={styles.folderGroupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + folderGroupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx new file mode 100644 index 00000000000..904687a4dbf --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -0,0 +1,132 @@ +import { css, cx } from '@emotion/css'; +import { ReactNode } from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { IconButton, Stack, useStyles2 } from '@grafana/ui'; + +import { Spacer } from '../../components/Spacer'; + +interface GenericRowProps { + width: number; + title: ReactNode; + metadata?: ReactNode; + actions?: ReactNode; + content?: ReactNode; + isOpenByDefault?: boolean; + children?: ReactNode; + // allow overriding / adding styles for the row + leftColumnClassName?: string; + rightColumnClassName?: string; + depth?: number; // for indentation of nested rows +} + +export const GenericRow = ({ + width, + title, + metadata, + actions, + content, + isOpenByDefault = false, + children, + leftColumnClassName, + rightColumnClassName, + depth = 0, +}: GenericRowProps) => { + const styles = useStyles2(getStyles); + const [isOpen, handleToggle] = useToggle(isOpenByDefault); + + const hasChildren = Boolean(children); + const showChildContent = isOpen && hasChildren; + + return ( + <> +
+
+
+ +
+
+
+ {content &&
{content}
} +
+
+ {showChildContent ? children : null} + + ); +}; + +interface LeftCellProps { + title: ReactNode; + metadata?: ReactNode; + actions?: ReactNode; + isOpen?: boolean; + onToggle?: () => void; +} + +const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onToggle }: LeftCellProps) => { + const styles = useStyles2(getStyles); + + return ( + + {onToggle && ( + onToggle()} + className={styles.dropdownIcon} + variant="secondary" + size="md" + aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')} + /> + )} + + + {title} + {actions && } + {actions} + + {metadata} + + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + dropdownIcon: css({ + alignSelf: 'flex-start', + marginTop: theme.spacing(0.5), + }), + column: css({ + display: 'flex', + position: 'relative', + flexBasis: 0, + border: 'solid 1px transparent', + borderBottom: `1px solid ${theme.colors.border.medium}`, + borderLeft: `1px solid ${theme.colors.border.medium}`, + borderRight: `1px solid ${theme.colors.border.medium}`, + }), + leftColumn: css({ + overflow: 'hidden', + }), + columnContent: (depth?: number) => + css({ + padding: 5, + width: '100%', + paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5, + }), + groupItemWrapper: (width: number) => + css({ + display: 'grid', + gridTemplateColumns: `${width}px auto`, + gap: theme.spacing(2), + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/GroupRow.tsx b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx new file mode 100644 index 00000000000..4499672dd6e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { AlertLabel } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { GenericGroupedRow } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface GroupRowProps { + row: GenericGroupedRow; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; + children?: React.ReactNode; +} + +export const GroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: GroupRowProps) => { + const styles = useStyles2(getStyles); + + return ( + } + isOpenByDefault={true} + leftColumnClassName={styles.groupRow} + rightColumnClassName={styles.groupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + groupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx new file mode 100644 index 00000000000..7480d6f8a7e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -0,0 +1,114 @@ +import { css } from '@emotion/css'; +import { isEmpty } from 'lodash'; +import { useMemo } from 'react'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel } from '@grafana/scenes-react'; +import { GraphDrawStyle, VisibilityMode } from '@grafana/schema'; +import { + AxisPlacement, + BarAlignment, + LegendDisplayMode, + StackingMode, + Text, + TooltipDisplayMode, + useStyles2, +} from '@grafana/ui'; + +import { overrideToFixedColor } from '../../home/Insights'; + +import { GenericRow } from './GenericRow'; + +interface Instance { + labels: Labels; + series: DataFrame[]; +} + +interface InstanceRowProps { + instance: Instance; + commonLabels: Labels; + leftColumnWidth: number; + timeRange: TimeRange; + depth?: number; +} + +const chartConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.After) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden) + .setCustomFieldConfig('axisGridShow', false) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setMin(0) + .setMax(1) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange, depth = 0 }: InstanceRowProps) { + const styles = useStyles2(getStyles); + + const dataProvider = useMemo( + () => + new SceneDataNode({ + data: { + series: instance.series, + state: LoadingState.Done, + timeRange, + }, + }), + [instance, timeRange] + ); + + return ( + + + No labels + +
+ ) : ( + + ) + } + content={ + + } + depth={depth} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + minHeight: theme.spacing(2.5), + display: 'flex', + alignItems: 'center', + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/utils.ts b/public/app/features/alerting/unified/triage/rows/utils.ts new file mode 100644 index 00000000000..6a4d0731643 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/utils.ts @@ -0,0 +1,13 @@ +import { WorkbenchRow } from '../types'; + +// Generate unique keys for WorkbenchRow items +export function generateRowKey(row: WorkbenchRow, fallbackIndex: number): string { + if (row.type === 'alertRule') { + // Use ruleUID as primary key for AlertRuleRow + return `alert-${row.metadata.ruleUID}`; + } else { + // For GenericGroupedRow, create key from label and value + const groupedRow = row; + return `group-${groupedRow.metadata.label}-${groupedRow.metadata.value}`; + } +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx new file mode 100644 index 00000000000..9d2889d9cae --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx @@ -0,0 +1,113 @@ +import { omit } from 'lodash'; +import { useMemo } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { DataFrame, Labels, findCommonLabels } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { useQueryRunner, useTimeRange } from '@grafana/scenes-react'; +import { Box } from '@grafana/ui'; + +import { useWorkbenchContext } from '../WorkbenchContext'; +import { METRIC_NAME } from '../constants'; +import { GenericRow } from '../rows/GenericRow'; +import { InstanceRow } from '../rows/InstanceRow'; + +import { getDataQuery } from './utils'; + +function extractInstancesFromData(series: DataFrame[] | undefined) { + if (!series) { + return []; + } + + // 1. Group series by labels, ignoring alertstate + const groups = new Map(); + series.forEach((series) => { + const valueField = series.fields.find((f) => f.type !== 'time'); + if (!valueField) { + return; + } + + const keyLabels = omit(valueField.labels ?? {}, 'alertstate'); + const key = JSON.stringify(keyLabels); + + if (!groups.has(key)) { + groups.set(key, { labels: keyLabels, series: [] }); + } + groups.get(key)!.series.push(series); + }); + + return Array.from(groups.values()); +} + +type AlertRuleInstancesProps = { + ruleUID: string; + depth?: number; +}; + +export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesProps) { + const { leftColumnWidth } = useWorkbenchContext(); + const [timeRange] = useTimeRange(); + + const query = getDataQuery( + `count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{grafana_rule_uid="${ruleUID}"})`, + { format: 'timeseries', legendFormat: '{{alertstate}}' } + ); + + const queryRunner = useQueryRunner({ queries: [query] }); + + const isLoading = !queryRunner.isDataReadyToDisplay(); + const { data } = queryRunner.useState(); + + const instances = useMemo(() => extractInstancesFromData(data?.series), [data]); + + if (isLoading) { + return ; + } + + if (!instances.length && !isLoading) { + return ( + Alert instances} + depth={depth} + > +
+ No alert instances found for rule: {ruleUID} +
+
+ ); + } + + const allSeriesLabels: Labels[] = instances.map((instance) => instance.labels); + const commonLabels = allSeriesLabels.length === 1 ? {} : findCommonLabels(allSeriesLabels); + + return ( + <> + {instances.map((instance) => ( + + ))} + + ); +} + +export function GenericRowSkeleton({ width, depth }: { width: number; depth: number }) { + return ( + + + + } + depth={depth} + content={} + /> + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx new file mode 100644 index 00000000000..ac9cb28ef82 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx @@ -0,0 +1,93 @@ +import { VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel, useDataTransformer } from '@grafana/scenes-react'; +import { + AxisPlacement, + BarAlignment, + GraphDrawStyle, + LegendDisplayMode, + StackingMode, + TooltipDisplayMode, + VisibilityMode, +} from '@grafana/schema'; + +import { overrideToFixedColor } from '../../home/Insights'; +import { useWorkbenchContext } from '../WorkbenchContext'; + +/** + * Viz config for the alert rule summary chart - used by the React component + */ +export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.After) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden) + .setCustomFieldConfig('axisGridShow', false) + .setMin(0) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { + // Use WorkbenchContext to access the parent query runner and reuse its data + const { queryRunner } = useWorkbenchContext(); + + // Transform parent data to filter by this specific rule and partition by alert state + const transformedData = useDataTransformer({ + data: queryRunner, + transformations: [ + { + id: 'filterByValue', + options: { + filters: [ + { + config: { + id: 'equal', + options: { + value: ruleUID, + }, + }, + fieldName: 'grafana_rule_uid', + }, + ], + match: 'any', + type: 'include', + }, + }, + { + id: 'partitionByValues', + options: { + fields: ['alertstate'], + keepFields: false, + naming: { + asLabels: true, + }, + }, + }, + ], + }); + + return ( + + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx new file mode 100644 index 00000000000..9e8d24d152f --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx @@ -0,0 +1,54 @@ +import { SceneObjectBase, SceneObjectState, VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel, useQueryRunner } from '@grafana/scenes-react'; +import { BarAlignment, GraphDrawStyle, VisibilityMode } from '@grafana/schema'; +import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui'; + +import { overrideToFixedColor } from '../../home/Insights'; +import { METRIC_NAME } from '../constants'; + +import { getDataQuery, useQueryFilter } from './utils'; + +/** + * Viz config for the summary chart - used by the React component + */ +export const summaryChartVizConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.Center) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setMin(0) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function SummaryChartReact() { + const filter = useQueryFilter(); + + const dataProvider = useQueryRunner({ + queries: [ + getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { + legendFormat: '{{alertstate}}', // we need this so we can map states to the correct color in the vizConfig + }), + ], + }); + + return ; +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryChartScene extends SceneObjectBase { + static Component = SummaryChartReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx new file mode 100644 index 00000000000..add4f86934e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -0,0 +1,65 @@ +import { DataFrameView } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { useQueryRunner } from '@grafana/scenes-react'; +import { Stack, Text } from '@grafana/ui'; + +import { Spacer } from '../../components/Spacer'; +import { METRIC_NAME } from '../constants'; + +import { getDataQuery, useQueryFilter } from './utils'; + +interface Frame { + alertstate: 'firing' | 'pending'; + Value: number; +} + +export function SummaryStatsReact() { + const filter = useQueryFilter(); + + const dataProvider = useQueryRunner({ + queries: [ + getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { + instant: true, + exemplar: false, + format: 'table', + }), + ], + }); + + const isLoading = !dataProvider.isDataReadyToDisplay; + const data = dataProvider.useState().data; + const firstFrame = data?.series?.at(0); + + if (isLoading || !firstFrame) { + return null; + } + + const dfv = new DataFrameView(firstFrame); + if (dfv.length === 0) { + return null; + } + + const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing'); + const firingCount = dfv.fields.Value.values[firingIndex] ?? 0; + + const pendingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'pending'); + const pendingCount = dfv.fields.Value.values[pendingIndex] ?? 0; + + return ( + + + + {{ firingCount }} firing instances + + + {{ pendingCount }} pending instances + + + ); +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryStatsScene extends SceneObjectBase { + static Component = SummaryStatsReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx new file mode 100644 index 00000000000..a7a8a51157e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx @@ -0,0 +1,69 @@ +import { DashboardCursorSync } from '@grafana/data'; +import { + AdHocFiltersVariable, + GroupByVariable, + SceneControlsSpacer, + SceneFlexLayout, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneVariableSet, + VariableValueSelectors, + behaviors, +} from '@grafana/scenes'; +import { EmbeddedSceneWithContext } from '@grafana/scenes-react'; + +import { DATASOURCE_UID } from '../constants'; + +import { WorkbenchSceneObject } from './Workbench'; +import { defaultTimeRange } from './utils'; + +const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair }); + +export const triageScene = new EmbeddedSceneWithContext({ + // this will allow us to share the cursor between all vizualizations + $behaviors: [cursorSync], + controls: [ + new VariableValueSelectors({}), + new SceneControlsSpacer(), + new SceneTimePicker({}), + new SceneRefreshPicker({}), + ], + $timeRange: new SceneTimeRange(defaultTimeRange), + $variables: new SceneVariableSet({ + variables: [ + new GroupByVariable({ + name: 'groupBy', + label: 'Group by', + datasource: { + type: 'prometheus', + uid: DATASOURCE_UID, + }, + allowCustomValue: true, + applyMode: 'manual', + value: 'grafana_folder', + }), + new AdHocFiltersVariable({ + name: 'filters', + label: 'Filters', + datasource: { + type: 'prometheus', + uid: DATASOURCE_UID, + }, + applyMode: 'manual', // we will construct the label matchers for the PromQL queries ourselves + allowCustomValue: true, + useQueriesAsFilterForOptions: true, + supportsMultiValueOperators: true, + filters: [], + baseFilters: [], + layout: 'combobox', + }), + ], + }), + body: new SceneFlexLayout({ + direction: 'column', + children: [new WorkbenchSceneObject({})], + }), +}); + +export const TriageScene = () => ; diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx new file mode 100644 index 00000000000..356a2924461 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -0,0 +1,133 @@ +import { ArrayValues } from 'type-fest'; + +import { DataFrame, PanelData } from '@grafana/data'; +import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react'; + +import { Workbench } from '../Workbench'; +import { DEFAULT_FIELDS, METRIC_NAME, VARIABLES } from '../constants'; +import { AlertRuleRow, GenericGroupedRow, WorkbenchRow } from '../types'; + +import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils'; + +export class WorkbenchSceneObject extends SceneObjectBase { + public static Component = WorkbenchRenderer; +} + +export function WorkbenchRenderer() { + const [timeRange] = useTimeRange(); + const domain = convertTimeRangeToDomain(timeRange); + + const [groupByKeys = []] = useVariableValues(VARIABLES.groupBy); + + const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(','); + const queryFilter = useQueryFilter(); + + const runner = useQueryRunner({ + queries: [ + getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${queryFilter}})`, { + format: 'table', + }), + ], + }); + const { data } = runner.useState(); + const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; + + return ; +} + +type DataPoint = Record, string> & Record; + +function createAlertRuleRows(dataPoints: DataPoint[]): AlertRuleRow[] { + const rules = new Map< + string, + { + alertname: string; + folder: string; + ruleUID: string; + } + >(); + + for (const dp of dataPoints) { + const ruleUID = dp.grafana_rule_uid; + if (!rules.has(ruleUID)) { + rules.set(ruleUID, { + alertname: dp.alertname, + folder: dp.grafana_folder, + ruleUID: ruleUID, + }); + } + } + + const result: AlertRuleRow[] = []; + for (const rule of rules.values()) { + result.push({ + type: 'alertRule', + metadata: { + title: rule.alertname, + folder: rule.folder, + ruleUID: rule.ruleUID, + }, + }); + } + return result; +} + +function groupData(dataPoints: DataPoint[], groupBy: string[], depth: number): WorkbenchRow[] { + if (depth >= groupBy.length) { + return createAlertRuleRows(dataPoints); + } + + const groupByKey = groupBy[depth]; + const grouped = new Map(); + + for (const dp of dataPoints) { + const key = String(dp[groupByKey] ?? 'undefined'); + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)?.push(dp); + } + + const result: GenericGroupedRow[] = []; + for (const [value, rows] of grouped.entries()) { + result.push({ + type: 'group', + metadata: { + label: groupByKey, + value: value, + }, + rows: groupData(rows, groupBy, depth + 1), + }); + } + + return result; +} + +// @TODO narrower types for PanelData! (if possible) +export function convertToWorkbenchRows(data: PanelData, groupBy: string[] = []): WorkbenchRow[] { + if (!data.series.at(0)?.fields.length) { + return []; + } + + const frame = data.series[0]; + if (!isValidFrame(frame)) { + return []; + } + + const allDataPoints = Array.from({ length: frame.length }, (_, i) => { + const dataPoint: DataPoint = Object.create(null); + frame.fields.forEach((field) => { + dataPoint[field.name] = field.values[i]; + }); + return dataPoint; + }); + + return groupData(allDataPoints, groupBy, 0); +} + +function isValidFrame(frame: DataFrame) { + const requiredFieldNames = ['Time', ...DEFAULT_FIELDS]; + const fieldNames = new Set(frame.fields.map((f) => f.name)); + return requiredFieldNames.every((name) => fieldNames.has(name)); +} diff --git a/public/app/features/alerting/unified/triage/scene/utils.ts b/public/app/features/alerting/unified/triage/scene/utils.ts new file mode 100644 index 00000000000..19886a760e0 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/utils.ts @@ -0,0 +1,54 @@ +import { TimeRange } from '@grafana/data'; +import { SceneDataQuery } from '@grafana/scenes'; +import { useVariableValue, useVariableValues } from '@grafana/scenes-react'; +import { DataSourceRef } from '@grafana/schema'; + +import { DATASOURCE_UID, VARIABLES } from '../constants'; +import { Domain } from '../types'; + +export function getDataQuery(expression: string, options?: Partial): SceneDataQuery { + const datasourceRef: DataSourceRef = { + type: 'prometheus', + uid: DATASOURCE_UID, + }; + + const query: SceneDataQuery = { + refId: 'query', + expr: expression, + instant: false, + datasource: datasourceRef, + ...options, + }; + + return query; +} + +/** + * Turns an array of "groupBy" keys into a Prometheus matcher such as key!="",key2!="" . + * This way we can show only instances that have a label that was grouped on. + */ +export function stringifyGroupFilter(groupBy: string[]) { + return groupBy.map((key) => `${key}!=""`).join(','); +} + +export const defaultTimeRange = { + from: 'now-4h', + to: 'now', +} as const; + +export function convertTimeRangeToDomain(timeRange: TimeRange): Domain { + return [timeRange.from.toDate(), timeRange.to.toDate()]; +} + +/** + * This hook will create a Prometheus label matcher string from the "groupBy" and "filters" variables + */ +export function useQueryFilter(): string { + const [groupBy = []] = useVariableValues(VARIABLES.groupBy); + const [filters = ''] = useVariableValue(VARIABLES.filters); + + const groupByFilter = stringifyGroupFilter(groupBy); + const queryFilter = [groupByFilter, filters].filter((s) => Boolean(s)).join(','); + + return queryFilter; +} diff --git a/public/app/features/alerting/unified/triage/types.ts b/public/app/features/alerting/unified/triage/types.ts new file mode 100644 index 00000000000..b599f46cb33 --- /dev/null +++ b/public/app/features/alerting/unified/triage/types.ts @@ -0,0 +1,24 @@ +export type Domain = [Date, Date]; +export type Filter = [key: string, operator: '=' | '=!', value: string]; + +export type WorkbenchRow = GenericGroupedRow | AlertRuleRow; + +export type TimelineEntry = [timestamp: number, state: 'firing' | 'pending']; + +export interface AlertRuleRow { + type: 'alertRule'; + metadata: { + title: string; + folder: string; + ruleUID: string; + }; +} + +export interface GenericGroupedRow { + type: 'group'; + metadata: { + label: string; + value: string; + }; + rows: WorkbenchRow[]; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2fa137fe730..0cdda8b0a2a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1535,6 +1535,9 @@ "group-status": { "content-the-group-is-being-deleted": "The group is being deleted" }, + "group-wrapper": { + "toggle": "Toggle group" + }, "header": { "tooltip-remove": "Remove expression \"{{refId}}\"" }, @@ -1710,6 +1713,9 @@ "placeholder-key": "key", "placeholder-value": "value" }, + "left-column": { + "label-instances": "Instances" + }, "link-to-contact-points": { "aria-label-view-or-create-contact-points": "View or create contact points", "view-or-create-contact-points": "View or create contact points" @@ -2025,6 +2031,12 @@ "body-selected-alertmanager-not-found": "The selected Alertmanager no longer exists or you may not have permission to access it. You can select a different Alertmanager from the dropdown.", "title-selected-alertmanager-not-found": "Selected Alertmanager not found." }, + "pages": { + "triage": { + "subtitle": "Learn about problems in your systems moments after they occur", + "title": "Triage" + } + }, "panel-alert-tab-content": { "alert": { "title-errors-loading-rules": "Errors loading rules" @@ -2916,6 +2928,13 @@ "title-warning": "Warning" } }, + "triage": { + "alert-instances": "Alert instances", + "firing-instances-count": "{{firingCount}} firing instances", + "no-instances-found": "No alert instances found for rule: {ruleUID}", + "no-labels": "No labels", + "pending-instances-count": "{{pendingCount}} pending instances" + }, "type-selector-button": { "add-expression": "Add expression" },