mirror of
https://github.com/grafana/grafana.git
synced 2026-03-13 15:29:48 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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)',
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
69
public/app/features/alerting/unified/triage/scene/queries.ts
Normal file
69
public/app/features/alerting/unified/triage/scene/queries.ts
Normal 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])))`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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\" .}}",
|
||||
|
||||
Reference in New Issue
Block a user