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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user