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[];
|
groupBy?: string[];
|
||||||
filterBy?: Filter[];
|
filterBy?: Filter[];
|
||||||
queryRunner: SceneQueryRunner;
|
queryRunner: SceneQueryRunner;
|
||||||
|
isLoading?: boolean;
|
||||||
hasActiveFilters?: 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 styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const isLoading = !queryRunner.isDataReadyToDisplay();
|
|
||||||
const [pageIndex, setPageIndex] = useState<number>(1);
|
const [pageIndex, setPageIndex] = useState<number>(1);
|
||||||
|
|
||||||
// Calculate once: show folder metadata only if not grouping by grafana_folder
|
// 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 { t } from '@grafana/i18n';
|
||||||
import { IconButton, Stack, Text } from '@grafana/ui';
|
import { Stack, Text } from '@grafana/ui';
|
||||||
|
|
||||||
import { MetaText } from '../../components/MetaText';
|
import { MetaText } from '../../components/MetaText';
|
||||||
import { RuleDetailsDrawer } from '../rule-details/RuleDetailsDrawer';
|
import { RuleDetailsDrawer } from '../rule-details/RuleDetailsDrawer';
|
||||||
@@ -10,6 +10,7 @@ import { AlertRuleSummary } from '../scene/AlertRuleSummary';
|
|||||||
import { AlertRuleRow as AlertRuleRowType } from '../types';
|
import { AlertRuleRow as AlertRuleRowType } from '../types';
|
||||||
|
|
||||||
import { GenericRow } from './GenericRow';
|
import { GenericRow } from './GenericRow';
|
||||||
|
import { OpenDrawerIconButton } from './OpenDrawerIconButton';
|
||||||
|
|
||||||
interface AlertRuleRowProps {
|
interface AlertRuleRowProps {
|
||||||
row: AlertRuleRowType;
|
row: AlertRuleRowType;
|
||||||
@@ -29,13 +30,13 @@ export const AlertRuleRow = ({
|
|||||||
const { ruleUID, folder, title } = row.metadata;
|
const { ruleUID, folder, title } = row.metadata;
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
const handleDrawerOpen = useCallback(() => {
|
||||||
setIsDrawerOpen(true);
|
setIsDrawerOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
const handleDrawerClose = useCallback(() => {
|
||||||
setIsDrawerOpen(false);
|
setIsDrawerOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,9 +45,7 @@ export const AlertRuleRow = ({
|
|||||||
width={leftColumnWidth}
|
width={leftColumnWidth}
|
||||||
title={<Text variant="body">{title}</Text>}
|
title={<Text variant="body">{title}</Text>}
|
||||||
actions={
|
actions={
|
||||||
<IconButton
|
<OpenDrawerIconButton
|
||||||
style={{ transform: 'rotate(180deg)' }}
|
|
||||||
name="web-section-alt"
|
|
||||||
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
|
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
|
||||||
onClick={handleDrawerOpen}
|
onClick={handleDrawerOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const GenericRow = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{content && <div className={styles.columnContent()}>{content}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog
|
|||||||
{onToggle && (
|
{onToggle && (
|
||||||
<IconButton
|
<IconButton
|
||||||
name={isOpen ? 'angle-down' : 'angle-right'}
|
name={isOpen ? 'angle-down' : 'angle-right'}
|
||||||
onClick={() => onToggle()}
|
onClick={onToggle}
|
||||||
className={styles.dropdownIcon}
|
className={styles.dropdownIcon}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -113,6 +113,10 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
leftColumn: css({
|
leftColumn: css({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}),
|
}),
|
||||||
|
rightColumnWrapper: css({
|
||||||
|
minWidth: 'min-content',
|
||||||
|
flexGrow: 1,
|
||||||
|
}),
|
||||||
columnContent: (depth?: number) =>
|
columnContent: (depth?: number) =>
|
||||||
css({
|
css({
|
||||||
padding: 5,
|
padding: 5,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { AlertLabels } from '@grafana/alerting/unstable';
|
import { AlertLabels } from '@grafana/alerting/unstable';
|
||||||
import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data';
|
import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data';
|
||||||
@@ -11,7 +11,6 @@ import { GraphDrawStyle, VisibilityMode } from '@grafana/schema';
|
|||||||
import {
|
import {
|
||||||
AxisPlacement,
|
AxisPlacement,
|
||||||
BarAlignment,
|
BarAlignment,
|
||||||
IconButton,
|
|
||||||
LegendDisplayMode,
|
LegendDisplayMode,
|
||||||
StackingMode,
|
StackingMode,
|
||||||
Text,
|
Text,
|
||||||
@@ -23,6 +22,7 @@ import { overrideToFixedColor } from '../../home/Insights';
|
|||||||
import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer';
|
import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer';
|
||||||
|
|
||||||
import { GenericRow } from './GenericRow';
|
import { GenericRow } from './GenericRow';
|
||||||
|
import { OpenDrawerIconButton } from './OpenDrawerIconButton';
|
||||||
|
|
||||||
interface Instance {
|
interface Instance {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
@@ -75,13 +75,13 @@ export function InstanceRow({
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
const handleDrawerOpen = useCallback(() => {
|
||||||
setIsDrawerOpen(true);
|
setIsDrawerOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
const handleDrawerClose = useCallback(() => {
|
||||||
setIsDrawerOpen(false);
|
setIsDrawerOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const dataProvider = useMemo(
|
const dataProvider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -117,9 +117,7 @@ export function InstanceRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<IconButton
|
<OpenDrawerIconButton
|
||||||
style={{ transform: 'rotate(180deg)' }}
|
|
||||||
name="web-section-alt"
|
|
||||||
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
|
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
|
||||||
onClick={handleDrawerOpen}
|
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 { VizConfigBuilders } from '@grafana/scenes';
|
||||||
import { VizPanel, useDataTransformer } from '@grafana/scenes-react';
|
import { VizPanel, useDataTransformer } from '@grafana/scenes-react';
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
TooltipDisplayMode,
|
TooltipDisplayMode,
|
||||||
VisibilityMode,
|
VisibilityMode,
|
||||||
} from '@grafana/schema';
|
} from '@grafana/schema';
|
||||||
|
import { Box } from '@grafana/ui';
|
||||||
|
|
||||||
import { overrideToFixedColor } from '../../home/Insights';
|
import { overrideToFixedColor } from '../../home/Insights';
|
||||||
import { useWorkbenchContext } from '../WorkbenchContext';
|
import { useWorkbenchContext } from '../WorkbenchContext';
|
||||||
@@ -41,8 +44,12 @@ export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries()
|
|||||||
)
|
)
|
||||||
.build();
|
.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();
|
const { queryRunner } = useWorkbenchContext();
|
||||||
|
|
||||||
// Transform parent data to filter by this specific rule and partition by alert state
|
// 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 { useEffect, useState, useTransition } from 'react';
|
||||||
import { ArrayValues } from 'type-fest';
|
|
||||||
|
|
||||||
import { DataFrame, PanelData } from '@grafana/data';
|
import { SceneObjectBase, SceneObjectState, sceneGraph, sceneUtils } from '@grafana/scenes';
|
||||||
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
|
||||||
import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react';
|
import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react';
|
||||||
|
|
||||||
import { Workbench } from '../Workbench';
|
import { Workbench } from '../Workbench';
|
||||||
import { DEFAULT_FIELDS, METRIC_NAME, VARIABLES } from '../constants';
|
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';
|
import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils';
|
||||||
|
|
||||||
export class WorkbenchSceneObject extends SceneObjectBase<SceneObjectState> {
|
export class WorkbenchSceneObject extends SceneObjectBase<SceneObjectState> {
|
||||||
@@ -20,7 +18,6 @@ export function WorkbenchRenderer() {
|
|||||||
const domain = convertTimeRangeToDomain(timeRange);
|
const domain = convertTimeRangeToDomain(timeRange);
|
||||||
|
|
||||||
const [groupByKeys = []] = useVariableValues<string>(VARIABLES.groupBy);
|
const [groupByKeys = []] = useVariableValues<string>(VARIABLES.groupBy);
|
||||||
|
|
||||||
const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(',');
|
const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(',');
|
||||||
const queryFilter = useQueryFilter();
|
const queryFilter = useQueryFilter();
|
||||||
|
|
||||||
@@ -32,124 +29,62 @@ export function WorkbenchRenderer() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { data } = runner.useState();
|
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;
|
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 (
|
return (
|
||||||
<Workbench
|
<Workbench
|
||||||
data={rows}
|
data={rows}
|
||||||
domain={domain}
|
domain={domain}
|
||||||
queryRunner={runner}
|
queryRunner={runner}
|
||||||
groupBy={groupByKeys}
|
groupBy={groupByKeys}
|
||||||
|
isLoading={isLoading}
|
||||||
hasActiveFilters={hasFiltersApplied}
|
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