diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 8723ce6..eb894a2 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -186,6 +186,23 @@ export interface UpdateScriptInput { sandbox?: ScriptSandbox; } +export interface DeadLetterRow { + id: string; + app_id: string; + source: string; + op: string; + trigger_id: string | null; + script_id: string | null; + payload: unknown; + attempt_count: number; + first_attempt_at: string; + last_attempt_at: string; + last_error: string; + created_at: string; + resolved_at: string | null; + resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null; +} + export interface ExecutionResult { status: number; headers: Record; @@ -516,6 +533,37 @@ export const api = { ) }, + deadLetters: { + count: (idOrSlug: string) => + adminRequest<{ unresolved: number }>( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/count` + ), + list: (idOrSlug: string, opts: { unresolved?: boolean; limit?: number; offset?: number } = {}) => { + const params = new URLSearchParams(); + if (opts.unresolved) params.set('unresolved', 'true'); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return adminRequest<{ dead_letters: DeadLetterRow[] }>( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters${qs ? `?${qs}` : ''}` + ); + }, + get: (idOrSlug: string, dlId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}` + ), + replay: (idOrSlug: string, dlId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/replay`, + { method: 'POST' } + ), + resolve: (idOrSlug: string, dlId: string, reason: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/resolve`, + { method: 'POST', body: JSON.stringify({ reason }) } + ) + }, + execute: async ( id: string, body: unknown, diff --git a/dashboard/src/routes/apps/+page.svelte b/dashboard/src/routes/apps/+page.svelte index 96b4dfe..d833a87 100644 --- a/dashboard/src/routes/apps/+page.svelte +++ b/dashboard/src/routes/apps/+page.svelte @@ -12,6 +12,26 @@ let listError = $state(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>({}); + 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 = {}; + 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 @@
{app.name} /{app.slug} + {#if unresolvedDl[app.id] > 0} + {unresolvedDl[app.id]} + {/if}
{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; } diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index d6f53b3..afc87b2 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -37,6 +37,20 @@ let domains = $state([]); let members = $state([]); + /// v1.1.1 dead-letters surface — design notes §4 mandates the + /// dashboard surface this since there's no default handler. + let unresolvedDeadLetters = $state(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[] = [loadScripts(app.id), loadDomains(app.id)]; + const loaders: Promise[] = [ + 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 + + Dead letters + {#if unresolvedDeadLetters > 0} + {unresolvedDeadLetters} + {/if} + {/if} @@ -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; diff --git a/dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte b/dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte new file mode 100644 index 0000000..18a40aa --- /dev/null +++ b/dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte @@ -0,0 +1,310 @@ + + + + Dead letters · {slug} · PiCloud + + +
+
+
+ ← back to {app?.name ?? slug} +

Dead letters

+

+ {#if unresolved > 0} + {unresolved} unresolved + {:else} + No unresolved dead letters + {/if} +

+
+
+ + +
+
+ + {#if error} +
{error}
+ {/if} + + {#if loading} +

Loading…

+ {:else if rows.length === 0} +

+ {#if unresolvedOnly} + No unresolved dead letters for this app. 🎉 + {:else} + No dead letters recorded yet. + {/if} +

+ {:else} + + + + + + + + + + + + + + + {#each rows as row (row.id)} + + + + + + + + + + + {#if expandedId === row.id} + + + + {/if} + {/each} + +
CreatedSourceOpScriptAttemptsFirst / Last attemptLast errorActions
{fmtTime(row.created_at)}{row.source}{row.op}{row.script_id ? row.script_id.slice(0, 8) : '—'}{row.attempt_count} +
{fmtTime(row.first_attempt_at)}
+
{fmtTime(row.last_attempt_at)}
+
+ + + {#if row.resolved_at === null} + + + {:else} + {row.resolution ?? 'resolved'} + {/if} +
+
+
+

Payload

+
{JSON.stringify(row.payload, null, 2)}
+
+
+

Last error

+
{row.last_error}
+
+
+
+ {/if} +
+ +