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

@@ -37,6 +37,20 @@
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
/// v1.1.1 dead-letters surface — design notes §4 mandates the
/// dashboard surface this since there's no default handler.
let unresolvedDeadLetters = $state<number>(0);
async function loadDeadLetterCount(idOrSlug: string) {
try {
const r = await api.deadLetters.count(idOrSlug);
unresolvedDeadLetters = r.unresolved;
} catch {
// Non-fatal: the page renders fine without the badge if
// the count endpoint is unreachable (e.g. older server).
unresolvedDeadLetters = 0;
}
}
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
@@ -107,7 +121,11 @@
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
const loaders: Promise<unknown>[] = [
loadScripts(app.id),
loadDomains(app.id),
loadDeadLetterCount(app.id)
];
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
@@ -421,6 +439,16 @@
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
<a
class="tab-link"
href="{base}/apps/{slug}/dead-letters"
title="Dead letters — replay or resolve events that exhausted their retry policy"
>
Dead letters
{#if unresolvedDeadLetters > 0}
<span class="dl-badge">{unresolvedDeadLetters}</span>
{/if}
</a>
{/if}
</nav>
@@ -871,6 +899,32 @@
border-bottom-color: #38bdf8;
}
.tabs .tab-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #94a3b8;
text-decoration: none;
padding: 0.6rem 1rem;
margin-left: auto;
border-bottom: 2px solid transparent;
font: inherit;
}
.tabs .tab-link:hover {
color: #e2e8f0;
}
.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;
}
button {
background: #38bdf8;
color: #0b1220;