mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Colour Alert History frequency bars by severity
The frequency chart drew every period in one blue under a legend of coloured severity dots that described nothing in the bars, left info alerts out of that legend, and used a floored log scale on which a 25-alert burst looked barely three times one alert. Each period now stacks critical, warning and info in the legend's own colours, counted with the alerts contract's closed severity vocabulary, and bars scale by the square root of their share of the busiest period so a burst stands clear while quiet periods keep a readable mix. The legend counts all three severities, and the tooltip and accessible name carry each period's breakdown. Light-theme warnings use amber-700 because yellow on the chart surface was under 2:1, and a selected period is ringed instead of dimming the rest below 3:1. The chart's two test files are registered as alerts frontend proofs.
This commit is contained in:
@@ -1544,6 +1544,20 @@ presentation and sort below warnings, while the history facet exposes a real
|
||||
Info option whose count and filtered rows use the same predicate as every
|
||||
other severity.
|
||||
|
||||
The alert history frequency chart in `AlertHistoryFrequencyCard.tsx` stacks
|
||||
each period by severity, critical at the base, from the `severityBuckets` that
|
||||
`buildAlertTrends` counts in the same pass as its totals. Those counts use
|
||||
`alertSeverityCountKey`, the closed vocabulary above: `error` is critical and
|
||||
an empty or unknown value is a warning, never information. Segment fills and
|
||||
legend dots share `getAlertSeverityFillClass`, each at least 3:1 against the
|
||||
chart surface in both themes (light-theme warnings use amber-700). A bar's
|
||||
height is the square root of its share of the busiest period (4px floor, 40px
|
||||
ceiling), so a burst stands well clear while ordinary periods stay tall
|
||||
enough to read their mix; each bar's tooltip and accessible name carry the
|
||||
exact counts. The legend counts critical, warnings and info (info only when
|
||||
present) over the current window and bar selection
|
||||
(`AlertHistoryFrequencyCard.test.tsx`, `alertHistoryModel.test.ts`).
|
||||
|
||||
The desktop alert history table sizes each column for its content instead of
|
||||
giving all visible columns an equal share. Each density is sized for the
|
||||
narrowest card it serves (600px, 800px, 1024px and 1280px): Time (the column
|
||||
|
||||
@@ -148,6 +148,14 @@ in alert helpers and the production held-breach browser fixture bind this
|
||||
shared presentation boundary to the alerts/API contracts.
|
||||
|
||||
|
||||
### Alert severity fills are one primitive
|
||||
|
||||
`getAlertSeverityFillClass` in `frontend-modern/src/utils/alertSeverityPresentation.ts`
|
||||
is the one fill per alert severity, and `getAlertSeverityDotClass` composes
|
||||
it, so a legend dot and the chart segment it names cannot drift apart. Each
|
||||
fill holds at least 3:1 against the chart surface in both themes; yellow on
|
||||
the light surface is under 2:1, so light-theme warnings use amber-700.
|
||||
|
||||
### Systemd backup-pause help separates shutdown from remote completion
|
||||
|
||||
The existing systemd precaution reads the stop result as well as the inactive
|
||||
|
||||
@@ -2518,6 +2518,8 @@
|
||||
"frontend-modern/src/components/Alerts/Thresholds/sections/__tests__/CollapsibleSection.test.tsx",
|
||||
"frontend-modern/src/components/Alerts/WebhookConfig.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertHistoryFrequencyCard.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "477a81e603792a131474e7c2f6b82ef197a99c11",
|
||||
"verified_at": "2026-10-14T20:38:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx",
|
||||
"frontend-modern/src/features/alerts/alertHistoryModel.ts",
|
||||
"frontend-modern/src/utils/alertSeverityPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx": "453028f18aedc497bc485add31efc53ecf1776e19169739ab6a944a3445e2b78",
|
||||
"frontend-modern/src/features/alerts/alertHistoryModel.ts": "a0d0ad493f06ac830ec45f588357aa6f76004be5b0a229a347bd13fbe9386491",
|
||||
"frontend-modern/src/utils/alertSeverityPresentation.ts": "c9225ccb725fd476ab5504d3c63ba2db960be02028204dc7406b957424065317"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/history"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Before (main): every frequency bar one blue; legend showed only warnings and critical; 23 alerts drew 25px against 8px for one alert",
|
||||
"After: each period stacks critical (base), warning and info in the legend colours; legend lists critical, warnings and info (info only when present)",
|
||||
"Square-root heights: busiest period 40px, quiet periods 8-16px with their severity mix readable",
|
||||
"Light theme: warning segments amber-700 (#b45309) on the slate-100 chart surface; dark theme keeps yellow-500",
|
||||
"Phone 375px: chart scrolls inside its card, legend on one line, no horizontal page overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"Hovered a period: tooltip reads \"4 alerts: 2 critical, 1 warning, 1 info\" with the period and range",
|
||||
"Selected a period: it is ringed, every other bar keeps full opacity, the list narrows to its alerts and the legend follows the selection",
|
||||
"Switched light and dark themes and compared segment and legend colours",
|
||||
"Repeated selection at 375x812 in dark theme"
|
||||
]
|
||||
}
|
||||
@@ -8,19 +8,53 @@ import {
|
||||
getAlertFrequencyClearFilterButtonClass,
|
||||
getAlertFrequencySelectionPresentation,
|
||||
} from '@/utils/alertFrequencyPresentation';
|
||||
import { getAlertSeverityDotClass } from '@/utils/alertSeverityPresentation';
|
||||
import {
|
||||
getAlertSeverityDotClass,
|
||||
getAlertSeverityFillClass,
|
||||
} from '@/utils/alertSeverityPresentation';
|
||||
|
||||
import { MS_PER_HOUR } from './alertHistoryModel';
|
||||
import { MS_PER_HOUR, alertSeverityCountKey, type AlertSeverityCounts } from './alertHistoryModel';
|
||||
import type { AlertHistoryState } from './useAlertHistoryState';
|
||||
|
||||
interface AlertHistoryFrequencyCardProps {
|
||||
state: AlertHistoryState;
|
||||
}
|
||||
|
||||
// Most severe first: the legend reads in this order and each bar stacks it
|
||||
// from the baseline up, so a period's critical alerts are never hidden.
|
||||
const SEVERITY_ORDER = ['critical', 'warning', 'info'] as const;
|
||||
const SEVERITY_NOUNS: Record<(typeof SEVERITY_ORDER)[number], [string, string]> = {
|
||||
critical: ['critical', 'critical'],
|
||||
warning: ['warning', 'warnings'],
|
||||
info: ['info', 'info'],
|
||||
};
|
||||
|
||||
const formatSeverityCount = (severity: (typeof SEVERITY_ORDER)[number], count: number) =>
|
||||
`${count} ${SEVERITY_NOUNS[severity][count === 1 ? 0 : 1]}`;
|
||||
|
||||
const formatSeverityBreakdown = (counts: AlertSeverityCounts | undefined) =>
|
||||
counts
|
||||
? SEVERITY_ORDER.filter((severity) => counts[severity] > 0)
|
||||
.map((severity) => formatSeverityCount(severity, counts[severity]))
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
// The busiest period fills the chart and the rest scale by the square root of
|
||||
// their share: a burst stands well clear of a quiet period (24 alerts against
|
||||
// one is five times taller) while ordinary periods stay tall enough to read
|
||||
// their severity mix. Each bar's tooltip carries the exact counts.
|
||||
const BAR_MAX_PX = 40;
|
||||
const BAR_MIN_PX = 4;
|
||||
|
||||
export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps) {
|
||||
const alertFrequencySelectionPresentation = createMemo(() =>
|
||||
getAlertFrequencySelectionPresentation(),
|
||||
);
|
||||
const severityTotals = createMemo(() => {
|
||||
const totals: AlertSeverityCounts = { critical: 0, warning: 0, info: 0 };
|
||||
for (const alert of props.state.alertData()) totals[alertSeverityCountKey(alert.severity)]++;
|
||||
return totals;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
@@ -67,32 +101,25 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
Clear filter
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<span class="flex items-center gap-1">
|
||||
<div class={getAlertSeverityDotClass('warning')}></div>
|
||||
{
|
||||
props.state.alertData().filter((alert) => alert.severity === 'warning').length
|
||||
}{' '}
|
||||
warnings
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<div class={getAlertSeverityDotClass('critical')}></div>
|
||||
{
|
||||
props.state.alertData().filter((alert) => alert.severity === 'critical').length
|
||||
}{' '}
|
||||
critical
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted" data-alert-frequency-legend>
|
||||
<For
|
||||
each={SEVERITY_ORDER.filter(
|
||||
(severity) => severity !== 'info' || severityTotals().info > 0,
|
||||
)}
|
||||
>
|
||||
{(severity) => (
|
||||
<span class="flex items-center gap-1">
|
||||
<span aria-hidden="true" class={getAlertSeverityDotClass(severity)}></span>
|
||||
{formatSeverityCount(severity, severityTotals()[severity])}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-1 text-[10px] text-muted">
|
||||
Showing {props.state.alertTrends().buckets.length} time periods (
|
||||
{props.state.bucketDurationLabel()} each) · Total: {props.state.alertData().length} alerts
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const trends = props.state.alertTrends();
|
||||
return (
|
||||
@@ -111,9 +138,15 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
<div class="rounded-sm bg-surface-alt p-1">
|
||||
<div class="flex h-12 items-end gap-1">
|
||||
{trends.buckets.map((value, index) => {
|
||||
const scaledHeight =
|
||||
value > 0 ? Math.min(100, Math.max(20, Math.log(value + 1) * 20)) : 0;
|
||||
const pixelHeight = value > 0 ? Math.max(8, (scaledHeight / 100) * 40) : 0;
|
||||
const pixelHeight =
|
||||
value > 0
|
||||
? Math.max(
|
||||
BAR_MIN_PX,
|
||||
Math.round(Math.sqrt(value / trends.max) * BAR_MAX_PX),
|
||||
)
|
||||
: 0;
|
||||
const counts = trends.severityBuckets?.[index];
|
||||
const breakdown = formatSeverityBreakdown(counts);
|
||||
const isSelected = () => props.state.selectedBarIndex() === index;
|
||||
const bucketStart = trends.bucketTimes[index];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
@@ -124,7 +157,7 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
: `${trends.bucketSize} hour${trends.bucketSize === 1 ? '' : 's'}`;
|
||||
const countLabel = getAlertBucketCountLabel(value);
|
||||
const tooltipContent = [
|
||||
countLabel,
|
||||
breakdown ? `${countLabel}: ${breakdown}` : countLabel,
|
||||
`${bucketDurationText} period`,
|
||||
bucketRangeLabel,
|
||||
].join('\n');
|
||||
@@ -135,7 +168,7 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
data-alert-frequency-bucket={index}
|
||||
class="relative flex h-12 min-w-6 flex-1 cursor-pointer items-end rounded-xs focus:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
|
||||
aria-pressed={isSelected()}
|
||||
aria-label={`${countLabel} between ${bucketRangeLabel}`}
|
||||
aria-label={`${breakdown ? `${countLabel} (${breakdown})` : countLabel} between ${bucketRangeLabel}`}
|
||||
onClick={() =>
|
||||
props.state.setSelectedBarIndex(
|
||||
index === props.state.selectedBarIndex() ? null : index,
|
||||
@@ -144,16 +177,10 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
>
|
||||
<span class="absolute bottom-0 h-1 w-full rounded-full bg-slate-300 opacity-30"></span>
|
||||
<span
|
||||
class="relative w-full rounded-xs transition-all"
|
||||
style={{
|
||||
height: `${pixelHeight}px`,
|
||||
'background-color':
|
||||
value > 0 ? (isSelected() ? '#2563eb' : '#3b82f6') : 'transparent',
|
||||
opacity: isSelected() ? '1' : '0.8',
|
||||
'box-shadow': isSelected()
|
||||
? '0 0 0 2px rgba(37, 99, 235, 0.4)'
|
||||
: 'none',
|
||||
}}
|
||||
data-alert-frequency-bar
|
||||
class="relative flex w-full flex-col-reverse overflow-hidden rounded-xs"
|
||||
classList={{ 'ring-2 ring-blue-500/60': isSelected() }}
|
||||
style={{ height: `${pixelHeight}px` }}
|
||||
title={bucketRangeLabel}
|
||||
onMouseEnter={(event) => {
|
||||
if (value <= 0) {
|
||||
@@ -167,7 +194,21 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
/>
|
||||
>
|
||||
<For
|
||||
each={SEVERITY_ORDER.filter(
|
||||
(severity) => (counts?.[severity] ?? 0) > 0,
|
||||
)}
|
||||
>
|
||||
{(severity) => (
|
||||
<span
|
||||
data-alert-frequency-segment={severity}
|
||||
class={`w-full shrink-0 ${getAlertSeverityFillClass(severity)}`}
|
||||
style={{ height: `${((counts?.[severity] ?? 0) / value) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,18 +7,28 @@ import type { AlertHistoryState } from '../useAlertHistoryState';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function createFrequencyCardState(options?: { withAxis?: boolean }) {
|
||||
const counts = (critical: number, warning: number, info: number) => ({ critical, warning, info });
|
||||
|
||||
function createFrequencyCardState(options?: {
|
||||
withAxis?: boolean;
|
||||
buckets?: number[];
|
||||
severityBuckets?: ReturnType<typeof counts>[];
|
||||
severities?: string[];
|
||||
}) {
|
||||
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
|
||||
const buckets = options?.buckets ?? [0, 1, 2];
|
||||
const state = {
|
||||
alertData: () => [],
|
||||
alertData: () => (options?.severities ?? []).map((severity) => ({ severity })),
|
||||
selectedBucketDetails: () => null,
|
||||
bucketDurationLabel: () => '1 hour',
|
||||
rangeSummary: () => null,
|
||||
selectedBarIndex,
|
||||
setSelectedBarIndex,
|
||||
alertTrends: () => ({
|
||||
buckets: [0, 1, 2],
|
||||
bucketTimes: [0, 60 * 60 * 1000, 2 * 60 * 60 * 1000],
|
||||
buckets,
|
||||
severityBuckets: options?.severityBuckets ?? buckets.map((value) => counts(0, value, 0)),
|
||||
max: Math.max(...buckets, 1),
|
||||
bucketTimes: buckets.map((_, index) => index * 60 * 60 * 1000),
|
||||
bucketSize: 1,
|
||||
}),
|
||||
formatBucketRange: (start: number) => `Period ${start}`,
|
||||
@@ -85,4 +95,78 @@ describe('AlertHistoryFrequencyCard', () => {
|
||||
expect(axisTicks).toHaveLength(2);
|
||||
expect(axisTicks.every((tick) => scrollSurface!.contains(tick))).toBe(true);
|
||||
});
|
||||
it('stacks each period by severity in the legend colours, critical at the base', () => {
|
||||
const { state } = createFrequencyCardState({
|
||||
buckets: [3, 0],
|
||||
severityBuckets: [counts(1, 2, 0), counts(0, 0, 0)],
|
||||
});
|
||||
const { container } = render(() => <AlertHistoryFrequencyCard state={state} />);
|
||||
|
||||
const bar = container.querySelector(
|
||||
'[data-alert-frequency-bucket="0"] [data-alert-frequency-bar]',
|
||||
);
|
||||
const segments = Array.from(
|
||||
bar!.querySelectorAll<HTMLElement>('[data-alert-frequency-segment]'),
|
||||
);
|
||||
// The bar stacks from the bottom (flex-col-reverse), so the first segment
|
||||
// sits on the baseline.
|
||||
expect(bar).toHaveClass('flex-col-reverse');
|
||||
expect(segments.map((segment) => segment.dataset.alertFrequencySegment)).toEqual([
|
||||
'critical',
|
||||
'warning',
|
||||
]);
|
||||
expect(segments[0]).toHaveClass('bg-red-500');
|
||||
expect(segments[1]).toHaveClass('bg-amber-700', 'dark:bg-yellow-500');
|
||||
expect(segments[0].style.height).toBe(`${(1 / 3) * 100}%`);
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
'[data-alert-frequency-bucket="1"] [data-alert-frequency-segment]',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
const button = container.querySelector('[data-alert-frequency-bucket="0"]');
|
||||
expect(button?.getAttribute('aria-label')).toContain('3 alerts (1 critical, 2 warnings)');
|
||||
});
|
||||
|
||||
it('scales bars from the busiest period so a burst stands well clear', () => {
|
||||
const { state } = createFrequencyCardState({ buckets: [1, 4, 25, 0] });
|
||||
const { container } = render(() => <AlertHistoryFrequencyCard state={state} />);
|
||||
const heights = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-alert-frequency-bar]'),
|
||||
).map((bar) => bar.style.height);
|
||||
|
||||
// Square root of each period's share of the busiest: 25 alerts read five
|
||||
// times taller than one (the old log scale drew 8px, 13px and 26px, so a
|
||||
// 25-alert burst looked barely three times one alert).
|
||||
expect(heights).toEqual(['8px', '16px', '40px', '0px']);
|
||||
});
|
||||
|
||||
it('counts every severity in the legend, info only when present', () => {
|
||||
const { state } = createFrequencyCardState({
|
||||
severities: ['critical', 'warning', 'warning', 'info', 'Critical'],
|
||||
});
|
||||
const { container } = render(() => <AlertHistoryFrequencyCard state={state} />);
|
||||
const legend = container.querySelector('[data-alert-frequency-legend]');
|
||||
expect(legend?.textContent).toBe('2 critical2 warnings1 info');
|
||||
|
||||
cleanup();
|
||||
const quiet = createFrequencyCardState({ severities: ['warning'] });
|
||||
const view = render(() => <AlertHistoryFrequencyCard state={quiet.state} />);
|
||||
expect(view.container.querySelector('[data-alert-frequency-legend]')?.textContent).toBe(
|
||||
'0 critical1 warning',
|
||||
);
|
||||
});
|
||||
|
||||
it('rings the selected period and keeps every other bar at full strength', () => {
|
||||
const { state } = createFrequencyCardState();
|
||||
const { container } = render(() => <AlertHistoryFrequencyCard state={state} />);
|
||||
fireEvent.click(container.querySelector('[data-alert-frequency-bucket="2"]')!);
|
||||
|
||||
// Dimming the rest would drop their fills under 3:1 on the light surface,
|
||||
// and they still carry severity and remain clickable.
|
||||
const bars = Array.from(container.querySelectorAll('[data-alert-frequency-bar]'));
|
||||
expect(bars[2]).toHaveClass('ring-2');
|
||||
expect(bars[1]).not.toHaveClass('ring-2');
|
||||
for (const bar of bars) expect(bar.className).not.toMatch(/opacity-/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,28 @@ describe('alertHistoryModel', () => {
|
||||
expect(details?.rangeLabel).toContain('Mar');
|
||||
});
|
||||
|
||||
it('counts each period by severity alongside its total', () => {
|
||||
const alerts = [
|
||||
{ id: 'a', startTime: '2026-03-22T09:05:00.000Z', severity: 'critical' },
|
||||
{ id: 'b', startTime: '2026-03-22T09:10:00.000Z', severity: 'warning' },
|
||||
{ id: 'c', startTime: '2026-03-22T09:20:00.000Z', severity: 'Warning' },
|
||||
{ id: 'd', startTime: '2026-03-22T09:40:00.000Z', severity: 'info' },
|
||||
{ id: 'e', startTime: '2026-03-22T08:10:00.000Z' },
|
||||
{ id: 'f', startTime: '2026-03-22T08:20:00.000Z', severity: 'error' },
|
||||
];
|
||||
const trends = buildAlertTrends(alerts as any, '24h', Date.UTC(2026, 2, 22, 10, 0, 0));
|
||||
const nine = trends.buckets.length - 1;
|
||||
|
||||
expect(trends.buckets[nine]).toBe(4);
|
||||
expect(trends.severityBuckets[nine]).toEqual({ critical: 1, warning: 2, info: 1 });
|
||||
// A missing severity fails safe to warning; legacy `error` is critical.
|
||||
expect(trends.severityBuckets[nine - 1]).toEqual({ critical: 1, warning: 1, info: 0 });
|
||||
trends.buckets.forEach((total, index) => {
|
||||
const counts = trends.severityBuckets[index];
|
||||
expect(counts.critical + counts.warning + counts.info).toBe(total);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stable id order for alerts sharing a startTime (#1218)', () => {
|
||||
const sharedStart = '2026-03-22T09:00:00.000Z';
|
||||
const items = [
|
||||
|
||||
@@ -61,6 +61,28 @@ export interface AlertTrendSeries {
|
||||
rangeHours: number;
|
||||
}
|
||||
|
||||
export interface AlertSeverityCounts {
|
||||
critical: number;
|
||||
warning: number;
|
||||
info: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frequency chart's series: each period's alerts by severity as well as
|
||||
* in total, so a busy period shows how bad it was.
|
||||
*/
|
||||
export interface AlertFrequencySeries extends AlertTrendSeries {
|
||||
severityBuckets: AlertSeverityCounts[];
|
||||
}
|
||||
|
||||
// The closed alert vocabulary: legacy `error` is critical, and an empty or
|
||||
// unknown value fails safe to warning rather than reading as information.
|
||||
export const alertSeverityCountKey = (severity: string | undefined): keyof AlertSeverityCounts => {
|
||||
const normalized = (severity ?? '').trim().toLowerCase();
|
||||
if (normalized === 'critical' || normalized === 'error') return 'critical';
|
||||
return normalized === 'info' ? 'info' : 'warning';
|
||||
};
|
||||
|
||||
export interface AlertAxisTick {
|
||||
position: number;
|
||||
label: string;
|
||||
@@ -326,7 +348,7 @@ export function buildAlertTrends(
|
||||
filteredAlerts: HistoryItem[],
|
||||
timeFilter: AlertHistoryRange,
|
||||
now = Date.now(),
|
||||
): AlertTrendSeries {
|
||||
): AlertFrequencySeries {
|
||||
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440];
|
||||
const maxBuckets = 30;
|
||||
|
||||
@@ -373,6 +395,11 @@ export function buildAlertTrends(
|
||||
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * MS_PER_HOUR);
|
||||
|
||||
const buckets = new Array(bucketCount).fill(0);
|
||||
const severityBuckets: AlertSeverityCounts[] = Array.from({ length: bucketCount }, () => ({
|
||||
critical: 0,
|
||||
warning: 0,
|
||||
info: 0,
|
||||
}));
|
||||
const bucketTimes = new Array(bucketCount)
|
||||
.fill(0)
|
||||
.map((_, index) => startTime + index * bucketSizeHours * MS_PER_HOUR);
|
||||
@@ -388,11 +415,13 @@ export function buildAlertTrends(
|
||||
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
|
||||
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
||||
buckets[bucketIndex]++;
|
||||
severityBuckets[bucketIndex][alertSeverityCountKey(alert.severity)]++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
buckets,
|
||||
severityBuckets,
|
||||
max: Math.max(...buckets, 1),
|
||||
bucketSize: bucketSizeHours,
|
||||
bucketTimes,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getAlertSeverityBadgeClass,
|
||||
getAlertSeverityCompactLabel,
|
||||
getAlertSeverityDotClass,
|
||||
getAlertSeverityFillClass,
|
||||
getAlertSeverityIndicatorVariant,
|
||||
getAlertSeverityTextClass,
|
||||
} from '@/utils/alertSeverityPresentation';
|
||||
@@ -60,10 +61,19 @@ describe('alertSeverityPresentation', () => {
|
||||
|
||||
it('maps severity dots to canonical colors', () => {
|
||||
expect(getAlertSeverityDotClass('critical')).toBe('h-2 w-2 rounded-full bg-red-500');
|
||||
expect(getAlertSeverityDotClass('warning')).toBe('h-2 w-2 rounded-full bg-yellow-500');
|
||||
expect(getAlertSeverityDotClass('warning')).toBe(
|
||||
'h-2 w-2 rounded-full bg-amber-700 dark:bg-yellow-500',
|
||||
);
|
||||
expect(getAlertSeverityDotClass('info')).toBe('h-2 w-2 rounded-full bg-blue-500');
|
||||
});
|
||||
|
||||
it('fills a severity the same colour as its dot, so a chart segment matches its legend', () => {
|
||||
for (const level of ['critical', 'warning', 'info', 'unknown']) {
|
||||
expect(getAlertSeverityDotClass(level)).toContain(getAlertSeverityFillClass(level));
|
||||
}
|
||||
expect(getAlertSeverityFillClass('CRITICAL')).toBe('bg-red-500');
|
||||
});
|
||||
|
||||
it('maps compact severity labels canonically', () => {
|
||||
expect(getAlertSeverityCompactLabel('critical')).toBe('CRIT');
|
||||
expect(getAlertSeverityCompactLabel('warning')).toBe('WARN');
|
||||
|
||||
@@ -95,13 +95,20 @@ export function getAlertSeverityCompactLabel(level: Alert['level'] | string): st
|
||||
return ALERT_SEVERITY_COMPACT_LABELS[normalized] || String(level).toUpperCase();
|
||||
}
|
||||
|
||||
export function getAlertSeverityDotClass(level: Alert['level'] | string): string {
|
||||
// One fill per severity, so a legend dot and the chart segment it names match.
|
||||
// Each holds at least 3:1 against the chart's surface in both themes; yellow
|
||||
// on the light surface is under 2:1, so light-theme warnings use amber-700.
|
||||
export function getAlertSeverityFillClass(level: Alert['level'] | string): string {
|
||||
switch (normalizeAlertSeverity(level)) {
|
||||
case 'critical':
|
||||
return 'h-2 w-2 rounded-full bg-red-500';
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'h-2 w-2 rounded-full bg-yellow-500';
|
||||
return 'bg-amber-700 dark:bg-yellow-500';
|
||||
default:
|
||||
return 'h-2 w-2 rounded-full bg-blue-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlertSeverityDotClass(level: Alert['level'] | string): string {
|
||||
return `h-2 w-2 rounded-full ${getAlertSeverityFillClass(level)}`;
|
||||
}
|
||||
|
||||
@@ -2486,6 +2486,7 @@ None yet.
|
||||
"frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx",
|
||||
"frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertHistoryFrequencyCard.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx",
|
||||
@@ -2493,6 +2494,7 @@ None yet.
|
||||
"frontend-modern/src/features/alerts/__tests__/OverviewTab.timelineerror.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx",
|
||||
"frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts",
|
||||
"frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts",
|
||||
"frontend-modern/src/features/alerts/__tests__/helpers.test.ts",
|
||||
"frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx",
|
||||
|
||||
Reference in New Issue
Block a user