Alerting: Add instance count badges to activity workbench (#117741)

* Alerting: add instance count badges to triage workbench rows

Replace the icon-only drawer button with a "Details" button and add firing/pending
instance count badges to triage workbench rows. Instance counts are derived from
the DataFrame using the latest timestamp per (ruleUID, alertstate) and aggregated
for grouped rows. Change workbench split to 50%/50%.

- Add InstanceCounts type and instanceCounts to WorkbenchRow
- Add InstanceCountBadges component with DrawerButtonSpacer for alignment
- Replace OpenDrawerIconButton with OpenDrawerButton (text label)
- Pre-compute rule counts in dataTransform via buildRuleCountsMap
- Add tests for data transform

* Update translations

* Alerting: replace DrawerButtonSpacer with CSS Grid layout

Replace the fragile invisible Button spacer with a RowActions component
that uses a 3-slot CSS Grid (1fr 1fr 2fr) for consistent column alignment
across rule rows and group rows. Also replace magic pixel numbers with
theme.spacing().

* Alerting: prevent instance count badges from shrinking on narrow screens

* Alerting: sort triage workbench rows alphabetically

Sort alert rule rows by title and group rows by label value using
case-insensitive locale comparison. Empty groups remain at the end.

* Alerting: deduplicate instance counts in triage workbench badges

Add a separate instant query that uses last_over_time + unless to
deduplicate alert instances that transitioned between pending and
firing during the selected time range. This prevents double-counting
and ensures each instance is counted only once in its latest state.

* Alerting: use deduplicated counts for summary stats and fix chart field names

Extract buildDeduplicatedExpr helper into utils.ts and use it in both
Workbench and SummaryStats so instance/rule counts reflect the selected
time range instead of the current instant.

Add renameByRegex transformation in AlertRuleSummary to restore the
Value field name after filterByRefId, fixing broken color overrides
caused by Prometheus renaming Value to Value #A with multiple queries.

* Alerting: consolidate triage PromQL queries into a single file

Move all PromQL query builders from SummaryChart, Workbench, SummaryStats,
and AlertRuleInstances into a dedicated queries.ts catalog for easier
inspection and maintenance.

* make text and icons a tad smaller

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik
2026-02-16 15:49:28 +01:00
committed by GitHub
parent 2086237dc3
commit cd376cfc0e
18 changed files with 1131 additions and 113 deletions

View File

@@ -31,7 +31,7 @@ type WorkbenchProps = {
hasActiveFilters?: boolean;
};
const initialSize = 1 / 3;
const initialSize = 1 / 2;
// Helper function to recursively render WorkbenchRow items with children pattern
function renderWorkbenchRow(

View File

@@ -10,7 +10,8 @@ import { AlertRuleSummary } from '../scene/AlertRuleSummary';
import { AlertRuleRow as AlertRuleRowType } from '../types';
import { GenericRow } from './GenericRow';
import { OpenDrawerIconButton } from './OpenDrawerIconButton';
import { RowActions } from './InstanceCountBadges';
import { OpenDrawerButton } from './OpenDrawerButton';
interface AlertRuleRowProps {
row: AlertRuleRowType;
@@ -45,9 +46,14 @@ export const AlertRuleRow = ({
width={leftColumnWidth}
title={<Text variant="body">{title}</Text>}
actions={
<OpenDrawerIconButton
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
onClick={handleDrawerOpen}
<RowActions
counts={row.instanceCounts}
actionButton={
<OpenDrawerButton
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
onClick={handleDrawerOpen}
/>
}
/>
}
metadata={

View File

@@ -9,6 +9,7 @@ import { MetaText } from '../../components/MetaText';
import { GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
import { RowActions } from './InstanceCountBadges';
interface FolderGroupRowProps {
row: GenericGroupedRow;
@@ -31,6 +32,7 @@ export const FolderGroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, childr
{isString(row.metadata.value) && <Text color="primary">{row.metadata.value}</Text>}
</Stack>
}
actions={<RowActions counts={row.instanceCounts} />}
isOpenByDefault={true}
leftColumnClassName={styles.folderGroupRow}
rightColumnClassName={styles.folderGroupRow}

View File

@@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui';
import { EmptyLabelValue, GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
import { RowActions } from './InstanceCountBadges';
import { formatLabelValue } from './utils';
interface GroupRowProps {
@@ -34,6 +35,7 @@ export const GroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }:
colorBy="key"
/>
}
actions={<RowActions counts={row.instanceCounts} />}
isOpenByDefault={!isEmptyValue}
leftColumnClassName={styles.groupRow}
rightColumnClassName={styles.groupRow}

View File

@@ -0,0 +1,74 @@
import { css } from '@emotion/css';
import { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Text, useStyles2 } from '@grafana/ui';
import { InstanceCounts } from '../types';
interface RowActionsProps {
counts: InstanceCounts;
actionButton?: ReactNode;
}
export function RowActions({ counts, actionButton }: RowActionsProps) {
const styles = useStyles2(getStyles);
const { firing, pending } = counts;
return (
<div className={styles.grid}>
<div className={styles.slot}>
{pending > 0 && (
<Text color="warning">
<span className={styles.badge}>
<Icon name="circle" size="xs" />
<CountText value={pending} />
</span>
</Text>
)}
</div>
<div className={styles.slot}>
{firing > 0 && (
<Text color="error">
<span className={styles.badge}>
<Icon name="exclamation-circle" size="xs" />
<CountText value={firing} />
</span>
</Text>
)}
</div>
<div className={styles.slot}>{actionButton}</div>
</div>
);
}
function CountText({ value }: { value: number }) {
const styles = useStyles2(getStyles);
return <span className={styles.countText}>{value}</span>;
}
const getStyles = (theme: GrafanaTheme2) => ({
grid: css({
display: 'grid',
gridTemplateColumns: '1fr 1fr 2fr',
gap: theme.spacing(0.5),
alignItems: 'center',
flexShrink: 0,
whiteSpace: 'nowrap',
}),
slot: css({
display: 'flex',
justifyContent: 'flex-end',
}),
badge: css({
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.25),
}),
countText: css({
...theme.typography.bodySmall,
display: 'inline-block',
minWidth: '1.5em',
textAlign: 'center',
}),
});

View File

@@ -22,7 +22,7 @@ import { overrideToFixedColor } from '../../home/Insights';
import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer';
import { GenericRow } from './GenericRow';
import { OpenDrawerIconButton } from './OpenDrawerIconButton';
import { OpenDrawerButton } from './OpenDrawerButton';
interface Instance {
labels: Labels;
@@ -117,7 +117,7 @@ export function InstanceRow({
)
}
actions={
<OpenDrawerIconButton
<OpenDrawerButton
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
onClick={handleDrawerOpen}
/>

View File

@@ -0,0 +1,20 @@
import { memo } from 'react';
import { Trans } from '@grafana/i18n';
import { Button } from '@grafana/ui';
interface OpenDrawerButtonProps {
onClick: () => void;
['aria-label']: string;
}
export const OpenDrawerButton = memo(function OpenDrawerButton({
onClick,
['aria-label']: ariaLabel,
}: OpenDrawerButtonProps) {
return (
<Button variant="secondary" fill="outline" size="sm" aria-label={ariaLabel} onClick={onClick}>
<Trans i18nKey="alerting.open-drawer-icon-button.details">Details</Trans>
</Button>
);
});

View File

@@ -1,24 +0,0 @@
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)',
}),
});

View File

@@ -8,11 +8,11 @@ import { useQueryRunner, useTimeRange } from '@grafana/scenes-react';
import { Box } from '@grafana/ui';
import { useWorkbenchContext } from '../WorkbenchContext';
import { METRIC_NAME } from '../constants';
import { GenericRow } from '../rows/GenericRow';
import { InstanceRow } from '../rows/InstanceRow';
import { getDataQuery, useQueryFilter } from './utils';
import { alertRuleInstancesQuery } from './queries';
import { useQueryFilter } from './utils';
function extractInstancesFromData(series: DataFrame[] | undefined) {
if (!series) {
@@ -49,13 +49,7 @@ export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesPro
const [timeRange] = useTimeRange();
const queryFilter = useQueryFilter();
const filters = queryFilter ? `grafana_rule_uid="${ruleUID}",${queryFilter}` : `grafana_rule_uid="${ruleUID}"`;
const query = getDataQuery(
`count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{${filters}})`,
{ format: 'timeseries', legendFormat: '{{alertstate}}' }
);
const queryRunner = useQueryRunner({ queries: [query] });
const queryRunner = useQueryRunner({ queries: [alertRuleInstancesQuery(ruleUID, queryFilter)] });
const isLoading = !queryRunner.isDataReadyToDisplay();
const { data } = queryRunner.useState();

View File

@@ -52,10 +52,23 @@ export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries()
function AlertRuleSummaryViz({ ruleUID }: { ruleUID: string }) {
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.
// filterByRefId ensures we use only the range query (A) for charts, excluding the
// instant badge query (B).
const transformedData = useDataTransformer({
data: queryRunner,
transformations: [
{
id: 'filterByRefId',
options: { include: 'A' },
},
{
id: 'renameByRegex',
options: {
regex: 'Value #A',
renamePattern: 'Value',
},
},
{
id: 'filterByValue',
options: {

View File

@@ -4,9 +4,9 @@ import { BarAlignment, GraphDrawStyle, VisibilityMode } from '@grafana/schema';
import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui';
import { overrideToFixedColor } from '../../home/Insights';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
import { summaryChartQuery } from './queries';
import { useQueryFilter } from './utils';
/**
* Viz config for the summary chart - used by the React component
@@ -38,11 +38,7 @@ export function SummaryChartReact() {
const filter = useQueryFilter();
const dataProvider = useQueryRunner({
queries: [
getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
legendFormat: '{{alertstate}}', // we need this so we can map states to the correct color in the vizConfig
}),
],
queries: [summaryChartQuery(filter)],
});
return <VizPanel title="" viz={summaryChartVizConfig} dataProvider={dataProvider} hoverHeader={true} />;

View File

@@ -4,12 +4,11 @@ import { DataFrameView, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner } from '@grafana/scenes-react';
import { Box, ErrorBoundaryAlert, Grid, useStyles2 } from '@grafana/ui';
import { Box, ErrorBoundaryAlert, Grid, Icon, type IconName, useStyles2 } from '@grafana/ui';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
import { summaryInstanceCountQuery, summaryRuleCountQuery } from './queries';
import { useQueryFilter } from './utils';
type AlertState = PromAlertingRuleState.Firing | PromAlertingRuleState.Pending;
@@ -79,10 +78,11 @@ interface StatBoxProps {
i18nKey: string;
value: number;
color: 'error' | 'warning';
icon?: IconName;
children: React.ReactNode;
}
function StatBox({ i18nKey, value, color, children }: StatBoxProps) {
function StatBox({ i18nKey, value, color, icon, children }: StatBoxProps) {
const styles = useStyles2(getStatBoxStyles);
const colorClass = color === 'error' ? styles.errorColor : styles.warningColor;
@@ -98,7 +98,10 @@ function StatBox({ i18nKey, value, color, children }: StatBoxProps) {
gap={1}
height="100%"
>
<div className={styles.label}>{children}</div>
<div className={styles.label}>
{icon && <Icon name={icon} size="sm" className={colorClass} />}
{children}
</div>
<div className={`${styles.value} ${colorClass}`}>{value}</div>
</Box>
);
@@ -106,6 +109,10 @@ function StatBox({ i18nKey, value, color, children }: StatBoxProps) {
const getStatBoxStyles = (theme: GrafanaTheme2) => ({
label: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(0.5),
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.primary,
wordWrap: 'break-word',
@@ -130,26 +137,18 @@ function SummaryStatsContent() {
const filter = useQueryFilter();
const alertstateFilter = parseAlertstateFilter(filter);
const instanceDataProvider = useQueryRunner({
queries: [getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { instant: true, format: 'table' })],
});
// Always remove alertstate filter from rule query to get accurate counts across both states
// This ensures we can count rules that have instances in either state
const ruleFilter = filter
// Strip alertstate from filter since the dedup queries add their own alertstate matchers
const cleanFilter = filter
.replace(/alertstate\s*=~?\s*"(firing|pending)"[,\s]*/, '')
.replace(/,\s*$/, '')
.replace(/^\s*,/, '');
const instanceDataProvider = useQueryRunner({
queries: [summaryInstanceCountQuery(cleanFilter)],
});
const ruleDataProvider = useQueryRunner({
queries: [
getDataQuery(
`count by (alertname, grafana_folder, grafana_rule_uid, alertstate) (${METRIC_NAME}{${ruleFilter}})`,
{
instant: true,
format: 'table',
}
),
],
queries: [summaryRuleCountQuery(cleanFilter)],
});
const { data: instanceData } = instanceDataProvider.useState();
@@ -180,20 +179,40 @@ function SummaryStatsContent() {
<Grid gap={2}>
{alertstateFilter.includes(PromAlertingRuleState.Firing) && (
<Grid columns={2} gap={2}>
<StatBox i18nKey="alerting.triage.firing-instances-count" value={instances.firing} color="error">
<StatBox
i18nKey="alerting.triage.firing-instances-count"
value={instances.firing}
color="error"
icon="exclamation-circle"
>
<Trans i18nKey="alerting.triage.firing-instances-count">Firing alert instances</Trans>
</StatBox>
<StatBox i18nKey="alerting.triage.firing-rules-count" value={rules.firing} color="error">
<StatBox
i18nKey="alerting.triage.firing-rules-count"
value={rules.firing}
color="error"
icon="exclamation-circle"
>
<Trans i18nKey="alerting.triage.rules-with-firing-instances">Alert rules with firing instances</Trans>
</StatBox>
</Grid>
)}
{alertstateFilter.includes(PromAlertingRuleState.Pending) && (
<Grid columns={2} gap={2}>
<StatBox i18nKey="alerting.triage.pending-instances-count" value={instances.pending} color="warning">
<StatBox
i18nKey="alerting.triage.pending-instances-count"
value={instances.pending}
color="warning"
icon="circle"
>
<Trans i18nKey="alerting.triage.pending-instances-count">Pending alert instances</Trans>
</StatBox>
<StatBox i18nKey="alerting.triage.rules-with-pending-instances" value={rules.pending} color="warning">
<StatBox
i18nKey="alerting.triage.rules-with-pending-instances"
value={rules.pending}
color="warning"
icon="circle"
>
<Trans i18nKey="alerting.triage.rules-with-pending-instances">Alert rules with pending instances</Trans>
</StatBox>
</Grid>

View File

@@ -1,13 +1,15 @@
import { useEffect, useState, useTransition } from 'react';
import { DataFrame } from '@grafana/data';
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 { DEFAULT_FIELDS, VARIABLES } from '../constants';
import { convertToWorkbenchRows } from './dataTransform';
import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils';
import { getWorkbenchQueries } from './queries';
import { convertTimeRangeToDomain, useQueryFilter } from './utils';
export class WorkbenchSceneObject extends SceneObjectBase<SceneObjectState> {
public static Component = WorkbenchRenderer;
@@ -22,11 +24,7 @@ export function WorkbenchRenderer() {
const queryFilter = useQueryFilter();
const runner = useQueryRunner({
queries: [
getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${queryFilter}})`, {
format: 'table',
}),
],
queries: getWorkbenchQueries(countBy, queryFilter),
});
const { data } = runner.useState();
@@ -57,9 +55,13 @@ export function WorkbenchRenderer() {
}
const { series } = newState.data;
// Use the badge frame (instant query B) for tree building and instance counts.
// The badge query deduplicates instances at the PromQL level.
const badgeFrame = findBadgeFrame(series);
// Use transition for non-blocking update
startTransition(() => {
setRows(convertToWorkbenchRows(series, currentGroupByKeys));
setRows(convertToWorkbenchRows(badgeFrame ? [badgeFrame] : series, currentGroupByKeys));
});
};
@@ -88,3 +90,13 @@ export function WorkbenchRenderer() {
/>
);
}
/**
* Finds the badge frame (instant query B) from the series.
* When multiple queries share a query runner, the Prometheus plugin renames
* the Value field to "Value #<refId>". We check both the frame's refId and
* the field naming convention.
*/
function findBadgeFrame(series: DataFrame[]): DataFrame | undefined {
return series.find((frame) => frame.refId === 'B' || frame.fields.some((f) => f.name === 'Value #B'));
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,68 @@
import { DataFrame } from '@grafana/data';
import { AlertRuleRow, EmptyLabelValue, GenericGroupedRow, WorkbenchRow } from '../types';
import { AlertRuleRow, EmptyLabelValue, GenericGroupedRow, InstanceCounts, WorkbenchRow } from '../types';
const EMPTY_COUNTS: InstanceCounts = { firing: 0, pending: 0 };
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
function sumCounts(rows: WorkbenchRow[]): InstanceCounts {
let firing = 0;
let pending = 0;
for (const row of rows) {
firing += row.instanceCounts.firing;
pending += row.instanceCounts.pending;
}
return { firing, pending };
}
/**
* Pre-computes a map of ruleUID → InstanceCounts by summing Values per (ruleUID, alertstate).
*
* Expects single-timestamp instant query data where deduplication has already been done
* at the PromQL level. When groupBy labels are active the query produces multiple rows
* per (ruleUID, alertstate) — one per group value — which are summed together.
*/
function buildRuleCountsMap(
frame: DataFrame,
fieldIndex: Map<string, number>,
ruleUIDValues: DataFrame['fields'][number]['values']
): Map<string, InstanceCounts> {
const alertstateIdx = fieldIndex.get('alertstate');
const valueIdx = fieldIndex.get('Value');
if (alertstateIdx === undefined || valueIdx === undefined) {
return new Map();
}
const alertstateValues = frame.fields[alertstateIdx].values;
const valueValues = frame.fields[valueIdx].values;
const result = new Map<string, InstanceCounts>();
for (let i = 0; i < frame.length; i++) {
const ruleUID = ruleUIDValues[i];
const alertstate = alertstateValues[i];
const value = valueValues[i] ?? 0;
if (!ruleUID || (alertstate !== 'firing' && alertstate !== 'pending')) {
continue;
}
let counts = result.get(ruleUID);
if (!counts) {
counts = { firing: 0, pending: 0 };
result.set(ruleUID, counts);
}
if (alertstate === 'firing') {
counts.firing += value;
} else {
counts.pending += value;
}
}
return result;
}
// Builds tree structure in one pass through data, avoiding intermediate row objects
export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] = []): WorkbenchRow[] {
@@ -11,9 +73,15 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
const frame = series[0];
// Build field index map
// When multiple queries share a Scenes query runner, the Prometheus plugin
// renames "Value" to "Value #<refId>" (e.g. "Value #B"). Normalize it back.
const fieldIndex = new Map<string, number>();
for (let i = 0; i < frame.fields.length; i++) {
fieldIndex.set(frame.fields[i].name, i);
const name = frame.fields[i].name;
fieldIndex.set(name, i);
if (name.startsWith('Value #')) {
fieldIndex.set('Value', i);
}
}
// Validate required fields exist
@@ -41,6 +109,13 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
const folderValues = frame.fields[folderIndex].values;
const ruleUIDValues = frame.fields[ruleUIDIndex].values;
// Pre-compute instance counts per rule
const ruleCountsMap = buildRuleCountsMap(frame, fieldIndex, ruleUIDValues);
function getRuleCounts(ruleUID: string): InstanceCounts {
return ruleCountsMap.get(ruleUID) ?? EMPTY_COUNTS;
}
// Get groupBy field value arrays
const groupByValueArrays = groupBy.map((key) => {
const index = fieldIndex.get(key);
@@ -63,9 +138,11 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
folder: folderValues[i],
ruleUID: ruleUID,
},
instanceCounts: getRuleCounts(ruleUID),
});
}
}
result.sort((a, b) => collator.compare(a.metadata.title, b.metadata.title));
return result;
}
@@ -123,10 +200,12 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
folder: folderValues[rowIdx],
ruleUID: ruleUID,
},
instanceCounts: getRuleCounts(ruleUID),
});
}
}
result.sort((a, b) => collator.compare(a.metadata.title, b.metadata.title));
return result;
}
@@ -134,13 +213,15 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
const emptyGroups: GenericGroupedRow[] = [];
for (const [value, childNode] of node.children.entries()) {
const childRows = nodeToRows(childNode, depth + 1);
const group: GenericGroupedRow = {
type: 'group',
metadata: {
label: groupBy[depth],
value: value,
},
rows: nodeToRows(childNode, depth + 1),
rows: childRows,
instanceCounts: sumCounts(childRows),
};
if (value === EmptyLabelValue) {
@@ -150,6 +231,7 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
}
}
result.sort((a, b) => collator.compare(String(a.metadata.value), String(b.metadata.value)));
return [...result, ...emptyGroups];
}

View File

@@ -0,0 +1,69 @@
import { SceneDataQuery } from '@grafana/scenes';
import { METRIC_NAME } from '../constants';
import { getDataQuery } from './utils';
/** Time series for the summary bar chart: count by alertstate */
export function summaryChartQuery(filter: string): SceneDataQuery {
return getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
legendFormat: '{{alertstate}}',
});
}
/** Range table query (A) for tree rows + deduplicated instant query (B) for badge counts */
export function getWorkbenchQueries(countBy: string, filter: string): [SceneDataQuery, SceneDataQuery] {
return [
getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${filter}})`, {
refId: 'A',
format: 'table',
}),
getDataQuery(getAlertsSummariesQuery(countBy, filter), {
refId: 'B',
instant: true,
range: false,
format: 'table',
}),
];
}
/** Deduplicated instant count by alertstate for summary instance counts */
export function summaryInstanceCountQuery(filter: string): SceneDataQuery {
return getDataQuery(getAlertsSummariesQuery('alertstate', filter), { instant: true, format: 'table' });
}
/** Deduplicated instant count by rule fields + alertstate for summary rule counts */
export function summaryRuleCountQuery(filter: string): SceneDataQuery {
return getDataQuery(getAlertsSummariesQuery('alertname, grafana_folder, grafana_rule_uid, alertstate', filter), {
instant: true,
format: 'table',
});
}
/** Instance timeseries for a specific alert rule */
export function alertRuleInstancesQuery(ruleUID: string, filter: string): SceneDataQuery {
const filters = filter ? `grafana_rule_uid="${ruleUID}",${filter}` : `grafana_rule_uid="${ruleUID}"`;
return getDataQuery(
`count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{${filters}})`,
{ format: 'timeseries', legendFormat: '{{alertstate}}' }
);
}
/**
* Builds a PromQL expression that counts deduplicated alert instances over the selected time range.
* Uses last_over_time to capture all instances active during the range, and `unless` to
* remove pending instances that also had a corresponding firing series.
* Firing takes priority over pending — instances that transitioned between states are
* counted only once in their firing state.
*/
function getAlertsSummariesQuery(countBy: string, filter: string): string {
const firingFilter = filter ? `alertstate="firing",${filter}` : 'alertstate="firing"';
const pendingFilter = filter ? `alertstate="pending",${filter}` : 'alertstate="pending"';
return (
`count by (${countBy}) (` +
`last_over_time(${METRIC_NAME}{${firingFilter}}[$__range]) or ` +
`(last_over_time(${METRIC_NAME}{${pendingFilter}}[$__range]) ` +
`unless ignoring(alertstate, grafana_alertstate) ` +
`last_over_time(${METRIC_NAME}{${firingFilter}}[$__range])))`
);
}

View File

@@ -5,6 +5,11 @@ export type WorkbenchRow = GenericGroupedRow | AlertRuleRow;
export type TimelineEntry = [timestamp: number, state: 'firing' | 'pending'];
export interface InstanceCounts {
firing: number;
pending: number;
}
export interface AlertRuleRow {
type: 'alertRule';
metadata: {
@@ -12,6 +17,7 @@ export interface AlertRuleRow {
folder: string;
ruleUID: string;
};
instanceCounts: InstanceCounts;
}
export interface GenericGroupedRow {
@@ -21,6 +27,7 @@ export interface GenericGroupedRow {
value: LabelValue;
};
rows: WorkbenchRow[];
instanceCounts: InstanceCounts;
}
export type LabelValue = string | typeof EmptyLabelValue;

View File

@@ -2308,6 +2308,9 @@
"recipient": "Recipient",
"recipient-notification-fires": "Select who should receive a notification when an alert rule fires."
},
"open-drawer-icon-button": {
"details": "Details"
},
"option-customfield": {
"label-custom-template": "Custom template",
"placeholder": "Enter plain text or reference a template, e.g. {{template \"default.message\" .}}",