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>
+53 -61
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { listRules, deleteRule, type AlertRule } from '$lib/api'; import { listRules, deleteRule, type AlertRule } from '$lib/api';
import { Button, Table, EmptyState, Skeleton } from '$lib/components/ui';
import AlertStatePill from '$lib/components/AlertStatePill.svelte';
let rules = $state<AlertRule[]>([]); let rules = $state<AlertRule[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -31,102 +33,92 @@
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' }; const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
return `${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`; return `${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`;
} }
// Firing rules first, then pending, then ok -- "what needs my
// attention" reads at the top of the list without having to scan
// every row (Phase 5 task 7's "at a glance" requirement).
const statePriority: Record<AlertRule['state']['state'], number> = { firing: 0, pending: 1, ok: 2 };
let sortedRules = $derived([...rules].sort((a, b) => statePriority[a.state.state] - statePriority[b.state.state]));
</script> </script>
<main> <main>
<h1>Alerts</h1> <div class="header">
<h1>Alerts</h1>
<Button href="/alerts/new" variant="primary">+ New rule</Button>
</div>
{#if error}<p class="error">Error: {error}</p>{/if} {#if error}<p class="error">Error: {error}</p>{/if}
<a class="new-rule" href="/alerts/new">+ New rule</a>
{#if loading} {#if loading}
<p>Loading…</p> <div class="skeleton-list">
{#each Array(3) as _, i (i)}
<Skeleton height="2.5rem" />
{/each}
</div>
{:else if rules.length === 0} {:else if rules.length === 0}
<p>No alert rules yet.</p> <EmptyState
icon="▲"
title="No alert rules yet"
description="Rules watch a query on an interval and notify you when it crosses a threshold, or goes silent."
>
{#snippet action()}
<Button href="/alerts/new" variant="primary">+ New rule</Button>
{/snippet}
</EmptyState>
{:else} {:else}
<table> <Table>
<thead> <thead>
<tr> <tr>
<th>State</th>
<th>Name</th> <th>Name</th>
<th>Condition</th> <th>Condition</th>
<th>State</th>
<th>Enabled</th> <th>Enabled</th>
<th></th> <th><span class="sr-only">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each rules as r (r.id)} {#each sortedRules as r (r.id)}
<tr> <tr>
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
<td><code>{conditionSummary(r)}</code></td>
<td> <td>
<span class="state" class:firing={r.state.state === 'firing'} class:pending={r.state.state === 'pending'}> <AlertStatePill state={r.state.state} />
{r.state.state}
</span>
{#if r.state.last_eval_status === 'error'} {#if r.state.last_eval_status === 'error'}
<span class="eval-error" title={r.state.last_error}>eval error</span> <span class="eval-error" title={r.state.last_error}>eval error</span>
{/if} {/if}
</td> </td>
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
<td><code>{conditionSummary(r)}</code></td>
<td>{r.enabled ? 'yes' : 'no'}</td> <td>{r.enabled ? 'yes' : 'no'}</td>
<td><button class="delete" onclick={() => remove(r.id)}>Delete</button></td> <td><Button size="sm" variant="danger" onclick={() => remove(r.id)}>Delete</Button></td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </Table>
{/if} {/if}
</main> </main>
<style> <style>
main { main {
font-family: system-ui, sans-serif; max-width: 60rem;
max-width: 960px; }
margin: 2rem auto; .header {
padding: 0 1rem; display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
h1 {
font-size: var(--text-xl);
} }
.error { .error {
color: #b00020; color: var(--color-danger);
} }
.new-rule { .skeleton-list {
display: inline-block; display: flex;
margin-bottom: 1rem; flex-direction: column;
color: #06c; gap: var(--space-2);
text-decoration: none;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.4rem 0.6rem;
text-align: left;
font-size: 0.9rem;
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
} }
.eval-error { .eval-error {
margin-left: 0.4rem; margin-left: var(--space-2);
font-size: 0.75rem; font-size: var(--text-xs);
color: #b00020; color: var(--color-danger);
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
} }
</style> </style>
+22 -93
View File
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { getRule, deleteRule, listDeliveries, type AlertRule, type DeliveryLogEntry } from '$lib/api'; import { getRule, deleteRule, listDeliveries, type AlertRule, type DeliveryLogEntry } from '$lib/api';
import { Button } from '$lib/components/ui';
import AlertStatePill from '$lib/components/AlertStatePill.svelte';
import DeliveryTimeline from '$lib/components/DeliveryTimeline.svelte';
const ruleId = page.params.id!; const ruleId = page.params.id!;
@@ -43,7 +46,7 @@
{:else} {:else}
<div class="header"> <div class="header">
<h1>{rule.name}</h1> <h1>{rule.name}</h1>
<button class="delete" onclick={remove}>Delete rule</button> <Button variant="danger" onclick={remove}>Delete rule</Button>
</div> </div>
{#if rule.description}<p class="desc">{rule.description}</p>{/if} {#if rule.description}<p class="desc">{rule.description}</p>{/if}
{#if error}<p class="error">Error: {error}</p>{/if} {#if error}<p class="error">Error: {error}</p>{/if}
@@ -51,13 +54,7 @@
<section class="summary"> <section class="summary">
<div> <div>
<span class="label">State</span> <span class="label">State</span>
<span <AlertStatePill state={rule.state.state} />
class="state"
class:firing={rule.state.state === 'firing'}
class:pending={rule.state.state === 'pending'}
>
{rule.state.state}
</span>
</div> </div>
<div><span class="label">Condition</span> {rule.condition_type}{conditionSummary(rule)}</div> <div><span class="label">Condition</span> {rule.condition_type}{conditionSummary(rule)}</div>
<div><span class="label">Query</span> <code>{rule.query}</code></div> <div><span class="label">Query</span> <code>{rule.query}</code></div>
@@ -74,45 +71,15 @@
{/if} {/if}
</section> </section>
<h2>Delivery log</h2> <h2>State history</h2>
<p class="hint">Most recent first — this is "why didn't I get paged."</p> <p class="hint">Most recent first — this is "why didn't I get paged."</p>
{#if deliveries.length === 0} <DeliveryTimeline {deliveries} />
<p>No deliveries yet.</p>
{:else}
<table>
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Status</th>
<th>Attempts</th>
<th>Response</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{#each deliveries as d (d.id)}
<tr>
<td>{new Date(d.created_at).toLocaleString()}</td>
<td>{d.event_type}</td>
<td>{d.status}</td>
<td>{d.attempt_count}</td>
<td>{d.response_status ?? '—'}</td>
<td class="error-cell">{d.last_error ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if} {/if}
</main> </main>
<style> <style>
main { main {
font-family: system-ui, sans-serif; max-width: 56rem;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
} }
.header { .header {
display: flex; display: flex;
@@ -120,69 +87,31 @@
justify-content: space-between; justify-content: space-between;
} }
.desc { .desc {
color: #555; color: var(--color-text-muted);
} }
.error { .error {
color: #b00020; color: var(--color-danger);
} }
.summary { .summary {
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: var(--radius-md);
padding: 0.75rem 1rem; padding: var(--space-3) var(--space-4);
margin: 1rem 0; margin: var(--space-4) 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: var(--space-1);
font-size: 0.9rem; font-size: var(--text-base);
background: var(--color-surface);
} }
.label { .label {
font-weight: 600; font-weight: var(--font-weight-medium);
margin-right: 0.4rem; margin-right: var(--space-1);
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
} }
.eval-error { .eval-error {
color: #b00020; color: var(--color-danger);
} }
.hint { .hint {
font-size: 0.8rem; font-size: var(--text-sm);
color: #777; color: var(--color-text-muted);
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.3rem 0.5rem;
text-align: left;
font-size: 0.85rem;
}
.error-cell {
color: #b00020;
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
</style> </style>
+17 -18
View File
@@ -193,49 +193,48 @@
<style> <style>
main { main {
font-family: system-ui, sans-serif; max-width: 45rem;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
} }
.error { .error {
color: #b00020; color: var(--color-danger);
} }
.field { .field {
display: block; display: block;
margin-bottom: 0.75rem; margin-bottom: var(--space-3);
font-size: 0.85rem; font-size: var(--text-sm);
color: var(--color-text-muted);
} }
.field input { .field input {
display: block; display: block;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
margin-top: 0.2rem; margin-top: var(--space-1);
} }
.hint { .hint {
font-size: 0.8rem; font-size: var(--text-sm);
color: #777; color: var(--color-text-muted);
} }
.row { .row {
display: flex; display: flex;
gap: 1rem; gap: var(--space-4);
align-items: flex-end; align-items: flex-end;
margin: 1rem 0; margin: var(--space-4) 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.row label { .row label {
font-size: 0.85rem; font-size: var(--text-sm);
color: var(--color-text-muted);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: var(--space-1);
} }
.new-target { .new-target {
display: flex; display: flex;
gap: 0.5rem; gap: var(--space-2);
margin-bottom: 1rem; margin-bottom: var(--space-4);
} }
.submit { .submit {
margin-top: 1rem; margin-top: var(--space-4);
padding: 0.4rem 1rem; padding: var(--space-2) var(--space-4);
} }
</style> </style>