From 720dfb65be62bd3e581d79cb2df8d28caa49a3bb Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 6 Nov 2025 17:39:27 +0100 Subject: [PATCH] Alerting: Alerts page performance improvements (#113391) * Add IntersectionObserver to render rule viz panel only when visible * Move data transformations to dataTransform file * Add tests for data transform * Use a new time series transformation algorithm * Reduce the number of time series data conversion * Memoize small components * Update tests * Remove some comments * Use Box component for styling. Remove unnecessary effect dependency --- .../alerting/unified/triage/Workbench.tsx | 15 +- .../unified/triage/rows/AlertRuleRow.tsx | 17 +- .../unified/triage/rows/GenericRow.tsx | 8 +- .../unified/triage/rows/InstanceRow.tsx | 16 +- .../triage/rows/OpenDrawerIconButton.tsx | 24 + .../unified/triage/scene/AlertRuleSummary.tsx | 65 +- .../unified/triage/scene/Workbench.tsx | 163 ++-- .../triage/scene/dataTransform.test.ts | 826 ++++++++++++++++++ .../unified/triage/scene/dataTransform.ts | 157 ++++ 9 files changed, 1151 insertions(+), 140 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/dataTransform.test.ts create mode 100644 public/app/features/alerting/unified/triage/scene/dataTransform.ts diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index 1d28aa3e285..66c6ccfec5b 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -27,6 +27,7 @@ type WorkbenchProps = { groupBy?: string[]; filterBy?: Filter[]; queryRunner: SceneQueryRunner; + isLoading?: boolean; hasActiveFilters?: boolean; }; @@ -115,13 +116,19 @@ function renderWorkbenchRow( │ │││ │ │ │ │ │ │ │ │ │ - │ │ │ │ - └─────────────────────────┘ └───────────────────────────────────┘ +│ │ │ │ +└─────────────────────────┘ └───────────────────────────────────┘ */ -export function Workbench({ domain, data, queryRunner, groupBy, hasActiveFilters = false }: WorkbenchProps) { +export function Workbench({ + domain, + data, + queryRunner, + groupBy, + isLoading = false, + hasActiveFilters = false, +}: WorkbenchProps) { const styles = useStyles2(getStyles); - const isLoading = !queryRunner.isDataReadyToDisplay(); const [pageIndex, setPageIndex] = useState(1); // Calculate once: show folder metadata only if not grouping by grafana_folder diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx index 965e9b5ce31..3e1a33b26aa 100644 --- a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { t } from '@grafana/i18n'; -import { IconButton, Stack, Text } from '@grafana/ui'; +import { Stack, Text } from '@grafana/ui'; import { MetaText } from '../../components/MetaText'; import { RuleDetailsDrawer } from '../rule-details/RuleDetailsDrawer'; @@ -10,6 +10,7 @@ import { AlertRuleSummary } from '../scene/AlertRuleSummary'; import { AlertRuleRow as AlertRuleRowType } from '../types'; import { GenericRow } from './GenericRow'; +import { OpenDrawerIconButton } from './OpenDrawerIconButton'; interface AlertRuleRowProps { row: AlertRuleRowType; @@ -29,13 +30,13 @@ export const AlertRuleRow = ({ const { ruleUID, folder, title } = row.metadata; const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const handleDrawerOpen = () => { + const handleDrawerOpen = useCallback(() => { setIsDrawerOpen(true); - }; + }, []); - const handleDrawerClose = () => { + const handleDrawerClose = useCallback(() => { setIsDrawerOpen(false); - }; + }, []); return ( <> @@ -44,9 +45,7 @@ export const AlertRuleRow = ({ width={leftColumnWidth} title={{title}} actions={ - diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx index b2a158f40e3..1588520fb48 100644 --- a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -54,7 +54,7 @@ export const GenericRow = ({ /> -
+
{content &&
{content}
}
@@ -79,7 +79,7 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog {onToggle && ( onToggle()} + onClick={onToggle} className={styles.dropdownIcon} variant="secondary" size="md" @@ -113,6 +113,10 @@ export const getStyles = (theme: GrafanaTheme2) => { leftColumn: css({ overflow: 'hidden', }), + rightColumnWrapper: css({ + minWidth: 'min-content', + flexGrow: 1, + }), columnContent: (depth?: number) => css({ padding: 5, diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx index 6bff83f9b20..a639d1c8efb 100644 --- a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { AlertLabels } from '@grafana/alerting/unstable'; import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data'; @@ -11,7 +11,6 @@ import { GraphDrawStyle, VisibilityMode } from '@grafana/schema'; import { AxisPlacement, BarAlignment, - IconButton, LegendDisplayMode, StackingMode, Text, @@ -23,6 +22,7 @@ import { overrideToFixedColor } from '../../home/Insights'; import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer'; import { GenericRow } from './GenericRow'; +import { OpenDrawerIconButton } from './OpenDrawerIconButton'; interface Instance { labels: Labels; @@ -75,13 +75,13 @@ export function InstanceRow({ const styles = useStyles2(getStyles); const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const handleDrawerOpen = () => { + const handleDrawerOpen = useCallback(() => { setIsDrawerOpen(true); - }; + }, []); - const handleDrawerClose = () => { + const handleDrawerClose = useCallback(() => { setIsDrawerOpen(false); - }; + }, []); const dataProvider = useMemo( () => @@ -117,9 +117,7 @@ export function InstanceRow({ ) } actions={ - diff --git a/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx b/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx new file mode 100644 index 00000000000..2a7e4df50eb --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/css'; +import { memo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, useStyles2 } from '@grafana/ui'; + +interface OpenDrawerIconButtonProps { + onClick: () => void; + ['aria-label']: string; +} + +export const OpenDrawerIconButton = memo(function OpenDrawerIconButton({ + onClick, + ['aria-label']: ariaLabel, +}: OpenDrawerIconButtonProps) { + const styles = useStyles2(getStyles); + return ; +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + iconButton: css({ + transform: 'rotate(180deg)', + }), +}); diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx index ac9cb28ef82..632c8ee453c 100644 --- a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef, useState } from 'react'; + import { VizConfigBuilders } from '@grafana/scenes'; import { VizPanel, useDataTransformer } from '@grafana/scenes-react'; import { @@ -9,6 +11,7 @@ import { TooltipDisplayMode, VisibilityMode, } from '@grafana/schema'; +import { Box } from '@grafana/ui'; import { overrideToFixedColor } from '../../home/Insights'; import { useWorkbenchContext } from '../WorkbenchContext'; @@ -41,8 +44,12 @@ export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries() ) .build(); -export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { - // Use WorkbenchContext to access the parent query runner and reuse its data +/** + * Component that contains the expensive hooks (queryRunner and data transformer). + * This component only renders when AlertRuleSummary determines the viewport is approaching, + * avoiding expensive CPU operations until necessary. + */ +function AlertRuleSummaryViz({ ruleUID }: { ruleUID: string }) { const { queryRunner } = useWorkbenchContext(); // Transform parent data to filter by this specific rule and partition by alert state @@ -91,3 +98,57 @@ export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { /> ); } + +/** + * Lazy-loaded component that uses Intersection Observer to only render the Viz component + * when the row is approaching the viewport. This prevents expensive CPU operations + * (queryRunner and data transformer) from running until necessary. + */ +export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { + const [isVisible, setIsVisible] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + // Check if Intersection Observer is supported + if (!window.IntersectionObserver) { + // Fallback: render immediately if Intersection Observer is not supported + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + // Process only the last entry (most recent state) + const entry = entries.at(-1); + if (entry) { + setIsVisible(entry.isIntersecting); + } + }, + { + rootMargin: '100px', // Start loading when element is 100px away from viewport + } + ); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, []); + + return ( + + {isVisible ? ( + + ) : ( + // Placeholder while not visible - maintains layout space + + )} + + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx index 7df0b354c13..2c0c1431892 100644 --- a/public/app/features/alerting/unified/triage/scene/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -1,14 +1,12 @@ -import { isEmpty } from 'lodash'; -import { ArrayValues } from 'type-fest'; +import { useEffect, useState, useTransition } from 'react'; -import { DataFrame, PanelData } from '@grafana/data'; -import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { SceneObjectBase, SceneObjectState, sceneGraph, sceneUtils } 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, EmptyLabelValue, GenericGroupedRow, WorkbenchRow } from '../types'; +import { convertToWorkbenchRows } from './dataTransform'; import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils'; export class WorkbenchSceneObject extends SceneObjectBase { @@ -20,7 +18,6 @@ export function WorkbenchRenderer() { const domain = convertTimeRangeToDomain(timeRange); const [groupByKeys = []] = useVariableValues(VARIABLES.groupBy); - const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(','); const queryFilter = useQueryFilter(); @@ -32,124 +29,62 @@ export function WorkbenchRenderer() { ], }); const { data } = runner.useState(); - const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; + const [rows, setRows] = useState>([]); + const [isPending, startTransition] = useTransition(); const hasFiltersApplied = queryFilter.length > 0; + // convertToWorkbenchRows is expensive when processing large datasets. + // We use runner.subscribeToState() instead of runner.useState() to transform data + // only when it actually changes. Using useState() triggers 2-3 unnecessary calls + // to convertToWorkbenchRows per update, even when wrapped in useMemo. + // Subscribe to runner state changes and transform data + useEffect(() => { + const transformData = (newState: typeof runner.state) => { + if (newState.data?.state !== 'Done' || !newState.data?.series) { + return; + } + + // Get the groupBy from the scene directly to avoid having groupByVariable in the dependency array + let currentGroupByKeys: string[] = []; + const groupByVariable = sceneGraph.lookupVariable(VARIABLES.groupBy, runner); + + if (groupByVariable && sceneUtils.isGroupByVariable(groupByVariable)) { + const value = groupByVariable.getValue(); + if (Array.isArray(value)) { + currentGroupByKeys = value.map((value) => String(value)); + } + } + + const { series } = newState.data; + // Use transition for non-blocking update + startTransition(() => { + setRows(convertToWorkbenchRows(series, currentGroupByKeys)); + }); + }; + + // Subscribe to state changes + const subscription = runner.subscribeToState((newState, prevState) => { + // Only transform if data actually changed + if (newState.data !== prevState.data) { + transformData(newState); + } + }); + + return () => subscription.unsubscribe(); + }, [runner]); + + const isDataLoading = data?.state === 'Loading'; + const isLoading = isDataLoading || isPending; + 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 mapKey = dp[groupByKey] ?? EmptyLabelValue; - if (!grouped.has(mapKey)) { - grouped.set(mapKey, []); - } - grouped.get(mapKey)?.push(dp); - } - - const result: GenericGroupedRow[] = []; - const emptyGroups: GenericGroupedRow[] = []; - - for (const [value, rows] of grouped.entries()) { - const labelValue = isEmpty(value) ? EmptyLabelValue : value; - - const group: GenericGroupedRow = { - type: 'group', - metadata: { - label: groupByKey, - value: labelValue, - }, - rows: groupData(rows, groupBy, depth + 1), - }; - - // Separate empty label groups to append at the end - if (group.metadata.value === EmptyLabelValue) { - emptyGroups.push(group); - } else { - result.push(group); - } - } - - return [...result, ...emptyGroups]; -} - -// @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/dataTransform.test.ts b/public/app/features/alerting/unified/triage/scene/dataTransform.test.ts new file mode 100644 index 00000000000..8bbef2a70d6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/dataTransform.test.ts @@ -0,0 +1,826 @@ +import { FieldType } from '@grafana/data'; + +import { EmptyLabelValue } from '../types'; + +import { convertToWorkbenchRows } from './dataTransform'; + +/** + * convertToWorkbenchRows transforms time series alert instance data into a hierarchical structure of alert rules. + * + * Input: DataFrame[] containing alert instances (firing/pending alerts) over time. + * Each alert instance includes metadata linking it to its parent alert rule via grafana_rule_uid. + * + * Output: A hierarchical structure where: + * - Multiple alert instances from the same rule are aggregated into a single alert rule row + * - Alert rules can be grouped by label values (team, severity, etc.) + * - Empty label values are placed at the end of each group level + */ +describe('convertToWorkbenchRows', () => { + describe('empty and invalid data handling', () => { + it('should return empty array when series is empty', () => { + const result = convertToWorkbenchRows([]); + + expect(result).toEqual([]); + }); + + it('should return empty array when series has no fields', () => { + const result = convertToWorkbenchRows([ + { + fields: [], + length: 0, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing required Time field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing required alertname field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing grafana_folder field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing grafana_rule_uid field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing alertstate field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + }); + + describe('no grouping - flat alert rule list', () => { + it('should aggregate alert instances into flat list of alert rules when no groupBy is provided', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert1'], config: {} }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder1'], + config: {}, + }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid1'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + ], + length: 3, + }, + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ]); + }); + + it('should return flat list when groupBy is empty array', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['TestFolder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['test-uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ], + [] + ); + + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'TestAlert', + folder: 'TestFolder', + ruleUID: 'test-uid', + }, + }, + ]); + }); + + it('should aggregate multiple alert instances from the same rule into a single alert rule', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert1', 'Alert2', 'Alert1'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder1', 'Folder2', 'Folder1'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid1', 'uid2', 'uid1'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'firing'], + config: {}, + }, + ], + length: 4, + }, + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ]); + }); + }); + + describe('single-level grouping', () => { + it('should group by single field', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert3'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2', 'Folder1'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid3'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'frontend', 'backend'], config: {} }, + ], + length: 3, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'backend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert3', + folder: 'Folder1', + ruleUID: 'uid3', + }, + }, + ], + }); + expect(result[1]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'frontend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + + it('should handle empty label values and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { name: 'team', type: FieldType.string, values: ['backend', '', 'frontend', ''], config: {} }, + ], + length: 4, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + expect(result[2].type).toBe('group'); + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + } + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe('frontend'); + } + if (result[2].type === 'group') { + expect(result[2].metadata.value).toBe(EmptyLabelValue); + } + }); + + it('should handle undefined label values and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert3'], config: {} }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3'], + config: {}, + }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid3'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', undefined, 'frontend'], config: {} }, + ], + length: 3, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + expect(result[2].type).toBe('group'); + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + } + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe('frontend'); + } + if (result[2].type === 'group') { + expect(result[2].metadata.value).toBe(EmptyLabelValue); + } + }); + + it('should handle all empty label values', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['', ''], config: {} }, + ], + length: 2, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: EmptyLabelValue, + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + }); + + describe('multi-level grouping', () => { + it('should group by two levels', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { + name: 'team', + type: FieldType.string, + values: ['backend', 'backend', 'frontend', 'frontend'], + config: {}, + }, + { + name: 'severity', + type: FieldType.string, + values: ['critical', 'warning', 'critical', 'info'], + config: {}, + }, + ], + length: 4, + }, + ], + ['team', 'severity'] + ); + + expect(result).toHaveLength(2); + + const backendGroup = result.find((r) => r.type === 'group' && r.metadata.value === 'backend'); + expect(backendGroup).toBeDefined(); + expect(backendGroup?.type).toBe('group'); + + if (backendGroup?.type === 'group') { + expect(backendGroup.rows).toHaveLength(2); + expect(backendGroup.rows[0]).toEqual({ + type: 'group', + metadata: { + label: 'severity', + value: 'critical', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + ], + }); + expect(backendGroup.rows[1]).toEqual({ + type: 'group', + metadata: { + label: 'severity', + value: 'warning', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + } + + const frontendGroup = result.find((r) => r.type === 'group' && r.metadata.value === 'frontend'); + expect(frontendGroup).toBeDefined(); + expect(frontendGroup?.type).toBe('group'); + + if (frontendGroup?.type === 'group') { + expect(frontendGroup.rows).toHaveLength(2); + } + }); + + it('should group by three levels', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'backend'], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'critical'], config: {} }, + { name: 'region', type: FieldType.string, values: ['us-east', 'us-west'], config: {} }, + ], + length: 2, + }, + ], + ['team', 'severity', 'region'] + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('group'); + + if (result[0].type === 'group') { + expect(result[0].metadata.label).toBe('team'); + expect(result[0].metadata.value).toBe('backend'); + expect(result[0].rows).toHaveLength(1); + + const severityGroup = result[0].rows[0]; + expect(severityGroup.type).toBe('group'); + + if (severityGroup.type === 'group') { + expect(severityGroup.metadata.label).toBe('severity'); + expect(severityGroup.metadata.value).toBe('critical'); + expect(severityGroup.rows).toHaveLength(2); + + const regionGroup1 = severityGroup.rows[0]; + const regionGroup2 = severityGroup.rows[1]; + + expect(regionGroup1.type).toBe('group'); + expect(regionGroup2.type).toBe('group'); + + if (regionGroup1.type === 'group' && regionGroup2.type === 'group') { + expect(regionGroup1.metadata.label).toBe('region'); + expect(regionGroup1.metadata.value).toBe('us-east'); + expect(regionGroup1.rows).toHaveLength(1); + + expect(regionGroup2.metadata.label).toBe('region'); + expect(regionGroup2.metadata.value).toBe('us-west'); + expect(regionGroup2.rows).toHaveLength(1); + } + } + } + }); + + it('should handle empty values at multiple levels and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { name: 'team', type: FieldType.string, values: ['backend', '', 'backend', ''], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'warning', '', 'info'], config: {} }, + ], + length: 4, + }, + ], + ['team', 'severity'] + ); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + expect(result[0].rows).toHaveLength(2); + const lastRow = result[0].rows[result[0].rows.length - 1]; + if (lastRow.type === 'group') { + expect(lastRow.metadata.value).toBe(EmptyLabelValue); + } + } + + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe(EmptyLabelValue); + expect(result[1].rows).toHaveLength(2); + } + }); + }); + + describe('alert instance aggregation with grouping', () => { + it('should aggregate alert instances from the same rule within groups', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert1', 'Alert2', 'Alert2'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder1', 'Folder2', 'Folder2'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid1', 'uid2', 'uid2'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { + name: 'team', + type: FieldType.string, + values: ['backend', 'backend', 'frontend', 'frontend'], + config: {}, + }, + ], + length: 4, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + + const backendGroup = result[0]; + if (backendGroup.type === 'group') { + expect(backendGroup.rows).toHaveLength(1); + expect(backendGroup.rows[0]).toEqual({ + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }); + } + + const frontendGroup = result[1]; + if (frontendGroup.type === 'group') { + expect(frontendGroup.rows).toHaveLength(1); + expect(frontendGroup.rows[0]).toEqual({ + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }); + } + }); + }); + + describe('grouping by non-existent field', () => { + it('should treat all values as empty when grouping by field that does not exist', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + ], + length: 2, + }, + ], + ['nonexistent'] + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'nonexistent', + value: EmptyLabelValue, + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + }); + + describe('additional fields', () => { + it('should work with extra fields in the data frame', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'frontend'], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'warning'], config: {} }, + { name: 'region', type: FieldType.string, values: ['us-east', 'us-west'], config: {} }, + { name: 'extra_field', type: FieldType.string, values: ['value1', 'value2'], config: {} }, + ], + length: 2, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'backend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + ], + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/dataTransform.ts b/public/app/features/alerting/unified/triage/scene/dataTransform.ts new file mode 100644 index 00000000000..d8677cad946 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/dataTransform.ts @@ -0,0 +1,157 @@ +import { DataFrame } from '@grafana/data'; + +import { AlertRuleRow, EmptyLabelValue, GenericGroupedRow, WorkbenchRow } from '../types'; + +// Builds tree structure in one pass through data, avoiding intermediate row objects +export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] = []): WorkbenchRow[] { + if (!series.at(0)?.fields.length) { + return []; + } + + const frame = series[0]; + + // Build field index map + const fieldIndex = new Map(); + for (let i = 0; i < frame.fields.length; i++) { + fieldIndex.set(frame.fields[i].name, i); + } + + // Validate required fields exist + if ( + !fieldIndex.has('Time') || + !fieldIndex.has('alertname') || + !fieldIndex.has('grafana_folder') || + !fieldIndex.has('grafana_rule_uid') || + !fieldIndex.has('alertstate') + ) { + return []; + } + + // Get required field value arrays (direct columnar access) + const alertnameIndex = fieldIndex.get('alertname'); + const folderIndex = fieldIndex.get('grafana_folder'); + const ruleUIDIndex = fieldIndex.get('grafana_rule_uid'); + + // These should always exist due to validation above, but handle gracefully + if (!alertnameIndex || !folderIndex || !ruleUIDIndex) { + return []; + } + + const alertnameValues = frame.fields[alertnameIndex].values; + const folderValues = frame.fields[folderIndex].values; + const ruleUIDValues = frame.fields[ruleUIDIndex].values; + + // Get groupBy field value arrays + const groupByValueArrays = groupBy.map((key) => { + const index = fieldIndex.get(key); + return index !== undefined ? frame.fields[index]?.values : undefined; + }); + + // Fast path: no grouping - just dedupe alert rules + if (groupBy.length === 0) { + const seen = new Set(); + const result: AlertRuleRow[] = []; + + for (let i = 0; i < frame.length; i++) { + const ruleUID = ruleUIDValues[i]; + if (ruleUID && !seen.has(ruleUID)) { + seen.add(ruleUID); + result.push({ + type: 'alertRule', + metadata: { + title: alertnameValues[i], + folder: folderValues[i], + ruleUID: ruleUID, + }, + }); + } + } + return result; + } + + // Build nested group structure in single pass + interface GroupNode { + children: Map; + rowIndices: number[]; + } + + const root: GroupNode = { + children: new Map(), + rowIndices: [], + }; + + // Single pass: build entire tree + for (let rowIdx = 0; rowIdx < frame.length; rowIdx++) { + let node = root; + + // Navigate/create path through tree + for (let depth = 0; depth < groupBy.length; depth++) { + const rawValue = groupByValueArrays[depth]?.[rowIdx]; + const value = rawValue === '' || rawValue === undefined ? EmptyLabelValue : rawValue; + + let childNode = node.children.get(value); + if (!childNode) { + childNode = { + children: new Map(), + rowIndices: [], + }; + node.children.set(value, childNode); + } + + node = childNode; + } + + // At leaf level, track row index + node.rowIndices.push(rowIdx); + } + + // Convert tree to WorkbenchRow format + function nodeToRows(node: GroupNode, depth: number): WorkbenchRow[] { + if (depth >= groupBy.length) { + // Leaf level - create alert rule rows + const seen = new Set(); + const result: AlertRuleRow[] = []; + + for (const rowIdx of node.rowIndices) { + const ruleUID = ruleUIDValues[rowIdx]; + if (ruleUID && !seen.has(ruleUID)) { + seen.add(ruleUID); + result.push({ + type: 'alertRule', + metadata: { + title: alertnameValues[rowIdx], + folder: folderValues[rowIdx], + ruleUID: ruleUID, + }, + }); + } + } + + return result; + } + + const result: GenericGroupedRow[] = []; + const emptyGroups: GenericGroupedRow[] = []; + + for (const [value, childNode] of node.children.entries()) { + const group: GenericGroupedRow = { + type: 'group', + metadata: { + label: groupBy[depth], + value: value, + }, + rows: nodeToRows(childNode, depth + 1), + }; + + if (value === EmptyLabelValue) { + emptyGroups.push(group); + } else { + result.push(group); + } + } + + return [...result, ...emptyGroups]; + } + + return nodeToRows(root, 0); +}