Redesign alerting UI: severity-colored state and a delivery timeline

AlertStatePill reuses the log-severity color tiers instead of a second
color vocabulary: ok -> quiet, pending -> warn, firing -> critical.
DeliveryTimeline reframes the existing delivery_log data (no new
backend fields) as a vertical timeline -- "why didn't I get paged" is a
chronological question a flat table answered less directly. Rules list
sorts firing-first. The list table's trailing actions column had a
bare empty <th>, which axe-core flags (empty-table-header) -- fixed
with visually-hidden text via app.css's shared .sr-only utility.
This commit is contained in:
2026-08-16 12:36:10 -07:00
parent 0e37ca6669
commit 8ec370dcee
5 changed files with 280 additions and 172 deletions
@@ -0,0 +1,62 @@
<script lang="ts">
// "Not just a text label — use the severity color system" (Phase 5
// task 7). Reuses the same four-tier severity tokens log data uses
// (see $lib/severity.ts) rather than inventing a second color
// vocabulary for alert state: ok -> quiet, pending -> warn,
// firing -> critical. A dot, not just colored text, so state reads
// at a glance in a dense rule list, not just on close reading.
import type { AlertRuleState } from '$lib/api';
let { state }: { state: AlertRuleState } = $props();
const tierFor: Record<AlertRuleState, 'quiet' | 'warn' | 'critical'> = {
ok: 'quiet',
pending: 'warn',
firing: 'critical'
};
</script>
<span class="pill {tierFor[state]}">
<span class="dot" aria-hidden="true"></span>
{state}
</span>
<style>
.pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
padding: 0.15rem var(--space-2);
border-radius: var(--radius-full);
text-transform: capitalize;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.quiet {
color: var(--color-sev-quiet);
background: var(--color-sev-quiet-bg);
}
.quiet .dot {
background: var(--color-sev-quiet);
}
.warn {
color: var(--color-sev-warn);
background: var(--color-sev-warn-bg);
}
.warn .dot {
background: var(--color-sev-warn);
}
.critical {
color: var(--color-sev-critical);
background: var(--color-sev-critical-bg);
}
.critical .dot {
background: var(--color-sev-critical);
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
// A firing/resolved delivery log IS a state-transition history --
// each row already carries a timestamp and an event_type, which is
// exactly a timeline's raw material. No new backend data was needed
// for "a timeline view of an alert's state history rather than just
// a flat delivery log" (Phase 5 task 7); this is the same
// DeliveryLogEntry list the old flat table read, framed differently.
import type { DeliveryLogEntry } from '$lib/api';
let { deliveries }: { deliveries: DeliveryLogEntry[] } = $props();
function tierFor(d: DeliveryLogEntry): 'critical' | 'quiet' {
return d.event_type === 'firing' ? 'critical' : 'quiet';
}
function statusLabel(d: DeliveryLogEntry): string {
if (d.status === 'sent') return 'delivered';
if (d.status === 'failed') return `failed${d.response_status ? ` (HTTP ${d.response_status})` : ''}`;
if (d.status === 'retrying') return `retrying (attempt ${d.attempt_count})`;
return d.status;
}
</script>
{#if deliveries.length === 0}
<p class="muted">No deliveries yet.</p>
{:else}
<ol class="timeline">
{#each deliveries as d (d.id)}
<li>
<span class="rail">
<span class="dot {tierFor(d)}"></span>
</span>
<div class="entry">
<div class="entry-head">
<span class="event {tierFor(d)}">{d.event_type}</span>
<time>{new Date(d.created_at).toLocaleString()}</time>
</div>
<div class="entry-body">
<span class:danger={d.status === 'failed'}>{statusLabel(d)}</span>
{#if d.last_error}<span class="error-text">{d.last_error}</span>{/if}
</div>
</div>
</li>
{/each}
</ol>
{/if}
<style>
.muted {
color: var(--color-text-muted);
}
.timeline {
list-style: none;
margin: 0;
padding: 0;
}
.timeline li {
display: grid;
grid-template-columns: 1.25rem 1fr;
gap: var(--space-3);
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
}
.rail::before {
content: '';
width: 1px;
flex: 1;
background: var(--color-border);
}
.timeline li:first-child .rail::before {
visibility: hidden;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
margin-top: 0.35rem;
border: 2px solid var(--color-bg);
box-shadow: 0 0 0 1px var(--color-border);
}
.dot.critical {
background: var(--color-sev-critical);
}
.dot.quiet {
background: var(--color-sev-quiet);
}
.entry {
padding-bottom: var(--space-4);
}
.entry-head {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.event {
font-weight: var(--font-weight-medium);
text-transform: capitalize;
font-size: var(--text-sm);
}
.event.critical {
color: var(--color-sev-critical);
}
.event.quiet {
color: var(--color-sev-quiet);
}
time {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.entry-body {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.danger {
color: var(--color-danger);
}
.error-text {
color: var(--color-danger);
}
</style>