feat(v1.1.1-dead-letters): dashboard badge + list view

Design notes §4 makes the dashboard surface load-bearing — with no
default DL handler, users wouldn't know dead letters exist
otherwise.

New route: `apps/[slug]/dead-letters/+page.svelte` — list view
columns per the design notes:
- `created_at`, `source`, `op`, `script_id`, `attempt_count`,
  `first/last_attempt_at`, `last_error` (truncated; clickable)
- per-row Replay + Mark resolved buttons
- expandable row detail panel showing full payload (JSON) +
  full last_error
- unresolved-only filter (default on); refresh button

Per-app detail page (`apps/[slug]/+page.svelte`) grows a "Dead
letters" link in the tabs nav, with a red unresolved-count pill
when > 0. Loaded in parallel with the existing app loaders so it
doesn't slow the page.

Apps list (`apps/+page.svelte`) shows the same red pill next to
each app's name when its unresolved count > 0. Counts fetched in
parallel after the apps list lands; failures here are non-fatal
(just no badge).

API client wiring: `api.deadLetters.{count,list,get,replay,resolve}`
mirrors the v1.1.1 admin endpoints. `DeadLetterRow` type added to
the dashboard's API shape declarations.

dashboard's svelte-check passes (369 files, 0 errors, 0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 22:21:20 +02:00
parent 20f1b5e64d
commit 1795dfc98a
4 changed files with 455 additions and 1 deletions

View File

@@ -12,6 +12,26 @@
let listError = $state<string | null>(null);
let loading = $state(true);
/// Unresolved-dead-letter count per app (v1.1.1). Loaded in
/// parallel after the app list. Failures here are non-fatal —
/// missing counts just don't render a badge.
let unresolvedDl = $state<Record<string, number>>({});
async function loadDlCounts(appList: App[]) {
const results = await Promise.all(
appList.map(async (a) => {
try {
const r = await api.deadLetters.count(a.id);
return [a.id, r.unresolved] as const;
} catch {
return [a.id, 0] as const;
}
})
);
const next: Record<string, number> = {};
for (const [id, count] of results) next[id] = count;
unresolvedDl = next;
}
let showCreate = $state(false);
let createSlug = $state('');
let createName = $state('');
@@ -49,6 +69,9 @@
listError = null;
try {
apps = await api.apps.list();
if (apps && apps.length > 0) {
void loadDlCounts(apps);
}
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
apps = null;
@@ -201,6 +224,12 @@
<div class="primary">
<strong>{app.name}</strong>
<span class="muted">/{app.slug}</span>
{#if unresolvedDl[app.id] > 0}
<span
class="dl-badge"
title="Unresolved dead letters in this app"
>{unresolvedDl[app.id]}</span>
{/if}
</div>
<div class="secondary muted">
{app.description ?? '—'}
@@ -246,6 +275,19 @@
cursor: not-allowed;
}
.dl-badge {
display: inline-block;
min-width: 1.25rem;
padding: 0.1rem 0.4rem;
background: #ef4444;
color: #fff;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
margin-left: 0.5rem;
}
.muted {
color: #64748b;
}