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
This commit is contained in:
Konrad Lalik
2025-11-06 17:39:27 +01:00
committed by GitHub
parent 7df3582237
commit 720dfb65be
9 changed files with 1151 additions and 140 deletions
@@ -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<number>(1);
// Calculate once: show folder metadata only if not grouping by grafana_folder
@@ -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={<Text variant="body">{title}</Text>}
actions={
<IconButton
style={{ transform: 'rotate(180deg)' }}
name="web-section-alt"
<OpenDrawerIconButton
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
onClick={handleDrawerOpen}
/>
@@ -54,7 +54,7 @@ export const GenericRow = ({
/>
</div>
</div>
<div style={{ minWidth: 'min-content', flexGrow: 1 }} className={cx(styles.column, rightColumnClassName)}>
<div className={cx(styles.rightColumnWrapper, styles.column, rightColumnClassName)}>
{content && <div className={styles.columnContent()}>{content}</div>}
</div>
</div>
@@ -79,7 +79,7 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog
{onToggle && (
<IconButton
name={isOpen ? 'angle-down' : 'angle-right'}
onClick={() => 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,
@@ -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={
<IconButton
style={{ transform: 'rotate(180deg)' }}
name="web-section-alt"
<OpenDrawerIconButton
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
onClick={handleDrawerOpen}
/>
@@ -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 <IconButton className={styles.iconButton} name="web-section-alt" aria-label={ariaLabel} onClick={onClick} />;
});
const getStyles = (theme: GrafanaTheme2) => ({
iconButton: css({
transform: 'rotate(180deg)',
}),
});
@@ -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<HTMLDivElement>(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 (
<Box ref={containerRef} width="100%" height="100%">
{isVisible ? (
<AlertRuleSummaryViz ruleUID={ruleUID} />
) : (
// Placeholder while not visible - maintains layout space
<Box width="100%" height="100%" />
)}
</Box>
);
}
@@ -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<SceneObjectState> {
@@ -20,7 +18,6 @@ export function WorkbenchRenderer() {
const domain = convertTimeRangeToDomain(timeRange);
const [groupByKeys = []] = useVariableValues<string>(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<ReturnType<typeof convertToWorkbenchRows>>([]);
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 (
<Workbench
data={rows}
domain={domain}
queryRunner={runner}
groupBy={groupByKeys}
isLoading={isLoading}
hasActiveFilters={hasFiltersApplied}
/>
);
}
type DataPoint = Record<ArrayValues<typeof DEFAULT_FIELDS>, string> & Record<string, string | undefined>;
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<string | typeof EmptyLabelValue, DataPoint[]>();
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));
}
@@ -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',
},
},
],
});
});
});
});
@@ -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<string, number>();
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<string>();
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<string | typeof EmptyLabelValue, GroupNode>;
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<string>();
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);
}