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:
@@ -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>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 loading = $state(true);
|
||||
@@ -31,102 +33,92 @@
|
||||
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
|
||||
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>
|
||||
|
||||
<main>
|
||||
<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}
|
||||
|
||||
<a class="new-rule" href="/alerts/new">+ New rule</a>
|
||||
|
||||
{#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}
|
||||
<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}
|
||||
<table>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>Name</th>
|
||||
<th>Condition</th>
|
||||
<th>State</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rules as r (r.id)}
|
||||
{#each sortedRules as r (r.id)}
|
||||
<tr>
|
||||
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
|
||||
<td><code>{conditionSummary(r)}</code></td>
|
||||
<td>
|
||||
<span class="state" class:firing={r.state.state === 'firing'} class:pending={r.state.state === 'pending'}>
|
||||
{r.state.state}
|
||||
</span>
|
||||
<AlertStatePill state={r.state.state} />
|
||||
{#if r.state.last_eval_status === 'error'}
|
||||
<span class="eval-error" title={r.state.last_error}>eval error</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
|
||||
<td><code>{conditionSummary(r)}</code></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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.new-rule {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: #06c;
|
||||
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;
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.eval-error {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #b00020;
|
||||
}
|
||||
.delete {
|
||||
color: #b00020;
|
||||
background: none;
|
||||
border: 1px solid #b00020;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
margin-left: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
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!;
|
||||
|
||||
@@ -43,7 +46,7 @@
|
||||
{:else}
|
||||
<div class="header">
|
||||
<h1>{rule.name}</h1>
|
||||
<button class="delete" onclick={remove}>Delete rule</button>
|
||||
<Button variant="danger" onclick={remove}>Delete rule</Button>
|
||||
</div>
|
||||
{#if rule.description}<p class="desc">{rule.description}</p>{/if}
|
||||
{#if error}<p class="error">Error: {error}</p>{/if}
|
||||
@@ -51,13 +54,7 @@
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">State</span>
|
||||
<span
|
||||
class="state"
|
||||
class:firing={rule.state.state === 'firing'}
|
||||
class:pending={rule.state.state === 'pending'}
|
||||
>
|
||||
{rule.state.state}
|
||||
</span>
|
||||
<AlertStatePill state={rule.state.state} />
|
||||
</div>
|
||||
<div><span class="label">Condition</span> {rule.condition_type} — {conditionSummary(rule)}</div>
|
||||
<div><span class="label">Query</span> <code>{rule.query}</code></div>
|
||||
@@ -74,45 +71,15 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<h2>Delivery log</h2>
|
||||
<h2>State history</h2>
|
||||
<p class="hint">Most recent first — this is "why didn't I get paged."</p>
|
||||
{#if deliveries.length === 0}
|
||||
<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}
|
||||
<DeliveryTimeline {deliveries} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
max-width: 56rem;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -120,69 +87,31 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
.desc {
|
||||
color: #555;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.summary {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin: var(--space-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-base);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.label {
|
||||
font-weight: 600;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
.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;
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
.eval-error {
|
||||
color: #b00020;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
}
|
||||
.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;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -193,49 +193,48 @@
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
max-width: 45rem;
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.field input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.2rem;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-end;
|
||||
margin: 1rem 0;
|
||||
margin: var(--space-4) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row label {
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.new-target {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.submit {
|
||||
margin-top: 1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user