feat(alerts): severity-first sorting, severity badges, threshold context

- Sort active alerts by severity (critical before warning) then recency
- Add explicit Critical/Warning text badge on each alert card
- Show 'limit: 90%' annotation for threshold-based alerts
- Add critical count breakdown to stats card

Makes alerts more scannable and actionable at a glance.
This commit is contained in:
rcourtman 2026-06-27 12:12:21 +01:00
parent 6c5139c16d
commit dcce40706e
3 changed files with 44 additions and 11 deletions

View file

@ -67,6 +67,19 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
{props.alert.resourceName}
</span>
<span class="text-xs text-muted">({alertTypeDisplayLabel(props.alert.type)})</span>
<Show when={!props.alert.acknowledged}>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
classList={{
'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300':
props.alert.level === 'critical',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300':
props.alert.level !== 'critical',
}}
>
{props.alert.level === 'critical' ? 'Critical' : 'Warning'}
</span>
</Show>
<Show when={props.alert.node}>
<span class="text-xs text-muted">
{getAlertOverviewNodeLabel(props.alert.nodeDisplayName || props.alert.node)}
@ -79,9 +92,19 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
</Show>
</div>
<p class="text-sm text-base-content mt-1 break-words">{props.alert.message}</p>
<p class={getAlertOverviewStartedAtClass()}>
{getAlertOverviewStartedAtLabel(new Date(props.alert.startTime).toLocaleString())}
</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 mt-1">
<p class={getAlertOverviewStartedAtClass()}>
{getAlertOverviewStartedAtLabel(new Date(props.alert.startTime).toLocaleString())}
</p>
<Show when={props.alert.threshold > 0}>
<span class="text-xs text-muted">
limit: {props.alert.threshold}
{props.alert.type === 'temperature' || props.alert.type === 'diskTemperature'
? '°C'
: '%'}
</span>
</Show>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start justify-end">

View file

@ -1,3 +1,5 @@
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { StatusDot } from '@/components/shared/StatusDot';
import { Table, TableBody, TableCell, TableRow } from '@/components/shared/Table';
@ -43,6 +45,11 @@ export function AlertOverviewStatsCards(props: AlertOverviewStatsCardsProps) {
<TableCell class={labelCellClass}>{labels().last24Hours}</TableCell>
<TableCell class={valueCellClass} data-testid="alert-overview-stat-value">
{props.state.alertStats().total24h}
<Show when={props.state.alertStats().critical24h > 0}>
<span class="ml-1.5 text-[10px] font-normal text-red-600 dark:text-red-400">
{props.state.alertStats().critical24h} critical
</span>
</Show>
</TableCell>
</TableRow>
<TableRow>

View file

@ -35,15 +35,17 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
const alertStats = createMemo(() => {
const alerts = effectiveAlerts();
const recent = alerts.filter((alert) => {
const ts = new Date(alert.startTime).getTime();
if (Number.isNaN(ts)) return true;
const age = tick() - ts;
return age >= 0 && age < 86_400_000;
});
return {
active: alerts.filter((alert) => !alert.acknowledged).length,
acknowledged: alerts.filter((alert) => alert.acknowledged).length,
total24h: alerts.filter((alert) => {
const ts = new Date(alert.startTime).getTime();
if (Number.isNaN(ts)) return true;
const age = tick() - ts;
return age >= 0 && age < 86_400_000;
}).length,
total24h: recent.length,
critical24h: recent.filter((alert) => alert.level === 'critical').length,
overrides: props.overrides().length,
};
});
@ -55,10 +57,11 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
if (a.acknowledged !== b.acknowledged) {
return a.acknowledged ? 1 : -1;
}
const severityRank = (level: string) => (level === 'critical' ? 0 : 1);
const severityDiff = severityRank(a.level) - severityRank(b.level);
if (severityDiff !== 0) return severityDiff;
const timeDiff = new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
if (timeDiff !== 0) return timeDiff;
// Stable id tiebreaker: alerts fired in the same polling cycle share a
// startTime and would otherwise scramble visually on re-render (#1218).
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}),
);