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:
@@ -186,6 +186,23 @@ export interface UpdateScriptInput {
|
|||||||
sandbox?: ScriptSandbox;
|
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 {
|
export interface ExecutionResult {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -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<DeadLetterRow>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}`
|
||||||
|
),
|
||||||
|
replay: (idOrSlug: string, dlId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/replay`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
),
|
||||||
|
resolve: (idOrSlug: string, dlId: string, reason: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/resolve`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ reason }) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -12,6 +12,26 @@
|
|||||||
let listError = $state<string | null>(null);
|
let listError = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
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 showCreate = $state(false);
|
||||||
let createSlug = $state('');
|
let createSlug = $state('');
|
||||||
let createName = $state('');
|
let createName = $state('');
|
||||||
@@ -49,6 +69,9 @@
|
|||||||
listError = null;
|
listError = null;
|
||||||
try {
|
try {
|
||||||
apps = await api.apps.list();
|
apps = await api.apps.list();
|
||||||
|
if (apps && apps.length > 0) {
|
||||||
|
void loadDlCounts(apps);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listError = e instanceof Error ? e.message : String(e);
|
listError = e instanceof Error ? e.message : String(e);
|
||||||
apps = null;
|
apps = null;
|
||||||
@@ -201,6 +224,12 @@
|
|||||||
<div class="primary">
|
<div class="primary">
|
||||||
<strong>{app.name}</strong>
|
<strong>{app.name}</strong>
|
||||||
<span class="muted">/{app.slug}</span>
|
<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>
|
||||||
<div class="secondary muted">
|
<div class="secondary muted">
|
||||||
{app.description ?? '—'}
|
{app.description ?? '—'}
|
||||||
@@ -246,6 +275,19 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,20 @@
|
|||||||
let domains = $state<AppDomain[]>([]);
|
let domains = $state<AppDomain[]>([]);
|
||||||
let members = $state<AppMemberDto[]>([]);
|
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
|
// Derive UI gates from the capabilities helper so the rules stay
|
||||||
// in lockstep with the backend's `can()`. canAdminApp also covers
|
// in lockstep with the backend's `can()`. canAdminApp also covers
|
||||||
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
||||||
@@ -107,7 +121,11 @@
|
|||||||
editName = app.name;
|
editName = app.name;
|
||||||
editDescription = app.description ?? '';
|
editDescription = app.description ?? '';
|
||||||
editSlug = app.slug;
|
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) {
|
if (canAdmin) {
|
||||||
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
||||||
}
|
}
|
||||||
@@ -421,6 +439,16 @@
|
|||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
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}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -871,6 +899,32 @@
|
|||||||
border-bottom-color: #38bdf8;
|
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 {
|
button {
|
||||||
background: #38bdf8;
|
background: #38bdf8;
|
||||||
color: #0b1220;
|
color: #0b1220;
|
||||||
|
|||||||
310
dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte
Normal file
310
dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, ApiError, type App, type DeadLetterRow } from '$lib/api';
|
||||||
|
|
||||||
|
let slug = $derived(page.params.slug ?? '');
|
||||||
|
let app = $state<App | null>(null);
|
||||||
|
let rows = $state<DeadLetterRow[]>([]);
|
||||||
|
let unresolved = $state<number>(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let unresolvedOnly = $state(true);
|
||||||
|
let expandedId = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const a = await api.apps.get(slug);
|
||||||
|
app = a;
|
||||||
|
const c = await api.deadLetters.count(slug);
|
||||||
|
unresolved = c.unresolved;
|
||||||
|
const r = await api.deadLetters.list(slug, { unresolved: unresolvedOnly, limit: 100 });
|
||||||
|
rows = r.dead_letters;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-load whenever the slug or filter changes.
|
||||||
|
void slug;
|
||||||
|
void unresolvedOnly;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function replay(dlId: string) {
|
||||||
|
try {
|
||||||
|
await api.deadLetters.replay(slug, dlId);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markIgnored(dlId: string) {
|
||||||
|
try {
|
||||||
|
await api.deadLetters.resolve(slug, dlId, 'ignored');
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(id: string) {
|
||||||
|
expandedId = expandedId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
if (s.length <= n) return s;
|
||||||
|
return s.slice(0, n) + '…';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dead letters · {slug} · PiCloud</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<a href="{base}/apps/{slug}" class="back">← back to {app?.name ?? slug}</a>
|
||||||
|
<h1>Dead letters</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
{#if unresolved > 0}
|
||||||
|
<strong class="badge">{unresolved}</strong> unresolved
|
||||||
|
{:else}
|
||||||
|
No unresolved dead letters
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={unresolvedOnly} />
|
||||||
|
Show unresolved only
|
||||||
|
</label>
|
||||||
|
<button onclick={load} disabled={loading}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
{#if unresolvedOnly}
|
||||||
|
No unresolved dead letters for this app. 🎉
|
||||||
|
{:else}
|
||||||
|
No dead letters recorded yet.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Op</th>
|
||||||
|
<th>Script</th>
|
||||||
|
<th>Attempts</th>
|
||||||
|
<th>First / Last attempt</th>
|
||||||
|
<th>Last error</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row (row.id)}
|
||||||
|
<tr class:resolved={row.resolved_at !== null}>
|
||||||
|
<td>{fmtTime(row.created_at)}</td>
|
||||||
|
<td><code>{row.source}</code></td>
|
||||||
|
<td><code>{row.op}</code></td>
|
||||||
|
<td>{row.script_id ? row.script_id.slice(0, 8) : '—'}</td>
|
||||||
|
<td>{row.attempt_count}</td>
|
||||||
|
<td class="times">
|
||||||
|
<div>{fmtTime(row.first_attempt_at)}</div>
|
||||||
|
<div>{fmtTime(row.last_attempt_at)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="err">
|
||||||
|
<button class="link" onclick={() => toggleExpanded(row.id)}>
|
||||||
|
{truncate(row.last_error, 60)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
{#if row.resolved_at === null}
|
||||||
|
<button onclick={() => replay(row.id)}>Replay</button>
|
||||||
|
<button class="secondary" onclick={() => markIgnored(row.id)}>
|
||||||
|
Mark resolved
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="resolution">{row.resolution ?? 'resolved'}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if expandedId === row.id}
|
||||||
|
<tr class="detail">
|
||||||
|
<td colspan="8">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section>
|
||||||
|
<h3>Payload</h3>
|
||||||
|
<pre>{JSON.stringify(row.payload, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Last error</h3>
|
||||||
|
<pre>{row.last_error}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: #c00;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #fee;
|
||||||
|
border: 1px solid #fbb;
|
||||||
|
color: #900;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border, #e0e0e0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
tr.resolved {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.times div {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.err button.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--link, #06c);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.actions button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text, #333);
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
}
|
||||||
|
.resolution {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
tr.detail td {
|
||||||
|
background: var(--bg-secondary, #fafafa);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.detail-grid section h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
.detail-grid pre {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border, #e0e0e0);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user