Report inbox: /api/reports in its own bounded reports.db, dashboard Reports page (list, filter, triage, copy as text)
Build and Push Docker Image / build (push) Failing after 1m20s

This commit is contained in:
Worph
2026-11-04 11:43:01 +00:00
parent cef9dba59e
commit f367ed037f
12 changed files with 1901 additions and 1 deletions
+8
View File
@@ -4,6 +4,7 @@ import Mounts from './pages/Mounts';
import Watchers from './pages/Watchers';
import Files from './pages/Files';
import Identity from './pages/Identity';
import Reports from './pages/Reports';
import ServiceNav from './components/ServiceNav';
const navStyle: React.CSSProperties = {
@@ -81,6 +82,12 @@ export default function App() {
>
Identity
</NavLink>
<NavLink
to="/reports"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Reports
</NavLink>
<NavLink
to="/editor"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
@@ -97,6 +104,7 @@ export default function App() {
<Route path="/watchers" element={<Watchers />} />
<Route path="/files" element={<Files />} />
<Route path="/identity" element={<Identity />} />
<Route path="/reports" element={<Reports />} />
<Route path="/editor" element={<EditorPage />} />
</Routes>
</div>
+271
View File
@@ -0,0 +1,271 @@
// How a report reads at a glance (the inbox row headline) and in full (the
// copy-as-text dump pasted into the Deck board).
//
// meta-core stores a report's body verbatim and knows nothing about it, so
// the meaning lives here, per app. The meta-watch headline is ported from the
// /info page that used to own reports (meta-watch ui/info.html,
// reportHeadline) — its comments explain why each chip earned its place; the
// short version is that every chip there is a stall or desync that once read
// as "healthy" until it was measured. Anything an app adds that this file does
// not know still renders: in the dump, and as a JSON section in the detail.
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Body = Record<string, any>;
export interface ReportSummary {
id: number;
ts: number;
received: number;
app: string;
build?: string;
uid?: string;
page?: string;
route?: string;
cid?: string;
title?: string;
note?: string;
status: 'open' | 'triaged' | 'closed';
triage_note?: string;
updated?: number;
log_lines: number;
bytes: number;
}
export interface Report extends ReportSummary {
body: Body;
}
/** One headline segment; `strong` ones are what a human wrote or a failure. */
export interface Bit {
text: string;
strong?: boolean;
}
export function fmtDur(ms: number): string {
if (ms == null || !isFinite(ms)) return '?';
if (ms < 1000) return `${Math.round(ms)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);
const r = Math.round(s % 60);
if (m < 60) return `${m}m${r ? ` ${r}s` : ''}`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
export function shortCid(cid: string): string {
return cid && cid.length > 16 ? `${cid.slice(0, 8)}…${cid.slice(-6)}` : cid || '';
}
const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
/** First line of the build blob, minus the app prefix: which build filed it. */
export function buildLabel(r: ReportSummary): string {
const first = (r.build || '').split('\n')[0];
return first.replace(/^meta-(watch|read|listen)( page| server)?\s+/, '').trim();
}
/** The device a report came from, in one line. */
export function deviceLine(d: any): string {
if (!d || typeof d !== 'object') return '';
const bits: string[] = [];
if (d.model) bits.push(d.model);
else if (d.platform) bits.push(d.platform);
if (d.isTv) bits.push(d.tvForced ? 'TV (forced)' : 'TV');
if (d.screen) bits.push(d.screen);
if (d.cores) bits.push(`${d.cores} cores`);
if (d.memoryGb) bits.push(`${d.memoryGb} GB`);
if (d.gpu) bits.push(d.gpu);
return bits.join(' · ');
}
/** What the row is about: a title, a page + route, or a cid. */
export function subject(r: ReportSummary, body?: Body): string {
const c = (body && body.context) || {};
if (r.page === 'search') return c.query ? `search “${c.query}”` : 'search';
if (r.title) return r.title;
if (r.page && r.page !== 'watch' && r.page !== 'read') return r.page;
if (r.cid) return shortCid(r.cid);
return r.page || '—';
}
// ── Headlines ─────────────────────────────────────────────────────────────
function failureBit(c: any): Bit | null {
const f = c && (c.failure || c.lastFailure);
if (!f) return null;
const who = c.lastFailure ? ` ${f.title || shortCid(f.cid || '')}` : '';
const tries = Array.isArray(f.attempts) && f.attempts.length > 1 ? ` · ${f.attempts.length} tries` : '';
return {
text: `FAILED${who}${f.elapsedMs != null ? ' after ' + fmtDur(f.elapsedMs) : ''}: ${clip(f.message || '', 90)}${tries}`,
strong: true,
};
}
function watchBits(r: ReportSummary, b: Body): string[] {
const bits: string[] = [];
const c = b.context || {};
if (r.page === 'search') {
if (c.state) bits.push(c.state === 'errored' ? 'STREAM ERRORED' : c.state);
if (c.anchored != null) {
bits.push(
`${c.anchored} anchored · ${c.raw || 0} raw` +
(c.shown != null && c.shown !== c.anchored ? ` · ${c.shown} after language filter` : '')
);
}
if (c.elapsedMs != null) bits.push(`stream ${fmtDur(c.elapsedMs)}`);
if (c.cacheSeeded) bits.push('painted from cache');
const ups = Array.isArray(c.upstreams) ? c.upstreams : [];
if (c.state !== 'idle' && c.state !== 'youtube-paste') {
if (ups.length) {
const recs = ups.reduce((n: number, u: any) => n + (u.records || 0), 0);
bits.push(`${ups.length} upstream${ups.length === 1 ? '' : 's'} (${recs} records)`);
} else bits.push('no upstream seen');
}
} else if (r.page === 'title') {
if (c.episodes != null) {
bits.push(`${c.episodes} release${c.episodes === 1 ? '' : 's'}` + (c.rawSources ? ` · ${c.rawSources} raw` : ''));
}
if (c.streamsOpen) bits.push(`${c.streamsOpen} stream${c.streamsOpen === 1 ? '' : 's'} open`);
else if (c.settled != null) bits.push(c.settled ? 'settled' : 'not settled');
if (c.placeholderCard) bits.push('placeholder hero');
}
if (c.lastPlayback) bits.push(`after playing ${c.lastPlayback.title || shortCid(c.lastPlayback.cid || '')}`);
const d = b.decode || {};
const p = b.pipeline || {};
if (d.underruns != null) bits.push(`${d.underruns} underrun${d.underruns === 1 ? '' : 's'}`);
if (d.minAhead != null) bits.push(`min ${(+d.minAhead).toFixed(1)}s ahead`);
if (d.decoded) {
const pct = (d.dropped / d.decoded) * 100;
bits.push(`${d.dropped} dropped (${pct.toFixed(pct >= 1 ? 1 : 2)}%)`);
}
if (d.capability) bits.push(d.capability);
if (p.pctDemuxCpu != null) bits.push(`demux CPU ${p.pctDemuxCpu}%`);
// Subtitle verdict: a stalled overlay is invisible in every other number.
const sub = b.subtitles;
if (sub && sub.up) {
const ago = sub.lastDrawAgoMs;
if (ago == null) bits.push('subs: NO DRAWS');
else if (ago > 4000) {
const why =
sub.paused === true ? ' while PAUSED'
: sub.advancing === true ? ' while playing'
: sub.paused === false ? ' (playing, not advancing)'
: '';
bits.push(`subs: STALLED ${fmtDur(ago)}${why}`);
} else bits.push(`subs ok (${sub.draws} draws)`);
if (sub.skipped > 0 && sub.draws > 0) {
const share = Math.round((100 * sub.skipped) / (sub.skipped + sub.draws));
if (share >= 15) bits.push(`subs: ${share}% of frames dropped`);
}
if (sub.commitMaxMs != null && sub.commitMaxMs > 250) {
bits.push(
`subs: commit ${sub.commitMs}/${sub.commitMaxMs}ms` +
(sub.pruned ? ` (${sub.lines}+${sub.pruned}p lines)` : ` (${sub.lines} lines)`)
);
}
if (sub.lastEventAgoMs != null && sub.lastEventAgoMs > 30000 && sub.advancing) {
bits.push(`subs: no packets for ${fmtDur(sub.lastEventAgoMs)}`);
}
if (sub.canvas && sub.canvas !== 'ok') bits.push(`subs canvas ${sub.canvas}`);
if (sub.videoW === 0 || sub.resizeDeferred) bits.push('subs: overlay resize owed (video has no size)');
else if (sub.resizesBlocked > 0) {
bits.push(`subs: ${sub.resizesBlocked} zero-size resize${sub.resizesBlocked > 1 ? 's' : ''} blocked`);
}
} else if (sub && sub.failed) {
const dd = sub.death;
if (!dd) bits.push('subs: fell back to WebVTT (build too old to say why)');
else {
bits.push(`subs: fell back to WebVTT at ${fmtDur(dd.mediaTime)} — ${dd.why}`);
if (dd.commitFailures) bits.push(`after ${dd.commitFailures} rejected commits`);
if (dd.strikes) bits.push(`after ${dd.strikes} stuck-loop strikes`);
bits.push(dd.draws ? `had drawn ${dd.draws} frames (${dd.lines}+${dd.pruned}p lines)` : 'never drew a frame');
if (dd.canvas && dd.canvas !== 'ok') bits.push(`canvas ${dd.canvas} at death`);
if (dd.resizesBlocked) bits.push(`${dd.resizesBlocked} zero-size resizes blocked`);
if (dd.videoW === 0) bits.push('video had no size at death');
}
}
// A/V sync: only the peak earns a chip.
const au = b.audio;
if (au && au.driftMaxMs != null && Math.abs(au.driftMaxMs) >= 40) {
const at = au.driftMaxAtSec != null ? ` @ ${fmtDur(au.driftMaxAtSec * 1000)}` : '';
bits.push(`A/V DESYNC ${au.driftMaxMs > 0 ? '+' : ''}${au.driftMaxMs}ms${at}`);
}
if (au && au.path === 'polyfill') bits.push(`audio ${au.codec || '?'} via polyfill`);
const rs = b.restart;
if (rs && rs.audioMinusVideoSec != null && rs.audioMinusVideoSec < -0.001) {
bits.push(`restart base from AUDIO (${rs.audioMinusVideoSec.toFixed(3)}s)`);
}
if (b.ttmvpMs != null) bits.push(`TTMVP ${fmtDur(b.ttmvpMs)}`);
if (b.capturedMsAgo != null && b.capturedMsAgo > 2000) bits.push(`measured ${fmtDur(b.capturedMsAgo)} before filing`);
return bits;
}
function readBits(r: ReportSummary, b: Body): string[] {
const bits: string[] = [];
const rd = b.reader;
if (rd && typeof rd === 'object') {
if (rd.page != null) bits.push(`page ${rd.page + 1}${rd.pages ? `/${rd.pages}` : ''}`);
if (rd.flow) bits.push(rd.flow);
if (rd.direction) bits.push(rd.direction);
if (rd.spread) bits.push('spread');
if (rd.strip && rd.strip.segments) bits.push(`strip ${rd.strip.active ?? '?'}/${rd.strip.segments}`);
if (rd.via) bits.push(`via ${rd.via}`);
if (rd.atEnd) bits.push('at end');
}
const c = b.context || {};
if (r.page === 'search') {
if (c.state) bits.push(c.state);
if (c.count != null) bits.push(`${c.count} shown`);
}
return bits;
}
/** The row headline: the note first (the only part a human wrote), then a
* failure if there was one, then the app's own measurements. */
export function headline(r: ReportSummary, body?: Body): Bit[] {
const out: Bit[] = [];
if (r.note) out.push({ text: `“${clip(r.note, 80)}”`, strong: true });
if (!body) return out;
const f = failureBit(body.context);
if (f) out.push(f);
const bits = r.app === 'meta-watch' ? watchBits(r, body) : r.app === 'meta-read' ? readBits(r, body) : [];
for (const t of bits) out.push({ text: t });
if (body.flags && typeof body.flags === 'object' && Object.keys(body.flags).length) {
out.push({ text: 'overrides: ' + Object.entries(body.flags).map(([k, v]) => `${k}=${v}`).join(' ') });
}
return out;
}
// ── Full text dump ────────────────────────────────────────────────────────
const LIFTED = new Set(['app', 'build', 'uid', 'page', 'route', 'cid', 'title', 'note', 'log']);
/** Every block of a report that is not one of the lifted fields or the log,
* in a stable order: context first, device second, then alphabetical. */
export function blocks(body: Body): [string, any][] {
const keys = Object.keys(body).filter((k) => !LIFTED.has(k));
const rank = (k: string) => (k === 'context' ? 0 : k === 'device' ? 1 : 2);
keys.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
return keys.map((k) => [k, body[k]]);
}
/** The whole report as text, for pasting into a ticket. */
export function dump(r: Report): string {
const parts: string[] = [];
parts.push(`REPORT #${r.id} · ${r.app} · ${new Date(r.ts).toISOString()} · ${r.status}` +
(r.uid ? `\nUID ${r.uid}` : ''));
if (r.note) parts.push('NOTE\n' + r.note);
if (r.page || r.route) parts.push('PAGE ' + (r.page || '—') + (r.route ? '\nROUTE ' + r.route : ''));
if (r.cid || r.title) parts.push(`CONTENT ${r.title || ''}${r.cid ? ` (${r.cid})` : ''}`.trim());
if (r.build) parts.push(r.build);
for (const [k, v] of blocks(r.body || {})) {
parts.push(k.toUpperCase() + '\n' + (typeof v === 'string' ? v : JSON.stringify(v, null, 2)));
}
const log: string[] = Array.isArray(r.body?.log) ? r.body.log : [];
if (log.length) parts.push(`LOG (${log.length} lines)\n` + log.join('\n'));
if (r.triage_note) parts.push('TRIAGE\n' + r.triage_note);
return parts.join('\n\n');
}
+406
View File
@@ -0,0 +1,406 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getJSON } from '../lib/api';
import {
blocks,
buildLabel,
deviceLine,
dump,
headline,
type Report,
type ReportSummary,
shortCid,
subject,
} from '../lib/reportText';
// The bug-report / feedback inbox. Client apps (meta-watch, meta-read, …)
// forward what their 🐛 buttons file to POST /api/reports; this page lists,
// reads and triages them. See internal/reports for storage and bounds.
type Status = 'open' | 'triaged' | 'closed';
const STATUSES: Status[] = ['open', 'triaged', 'closed'];
interface ListPage {
reports: ReportSummary[];
apps: Record<string, number>;
counts: Record<string, number>;
more: boolean;
}
const card: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
};
const chip = (active: boolean): React.CSSProperties => ({
padding: '0.25rem 0.7rem',
borderRadius: '999px',
border: '1px solid #0f3460',
background: active ? '#0f3460' : 'transparent',
color: active ? '#fff' : '#aaa',
cursor: 'pointer',
fontSize: '0.85rem',
});
const button: React.CSSProperties = {
padding: '0.35rem 0.8rem',
borderRadius: '4px',
border: '1px solid #0f3460',
background: '#0f3460',
color: '#fff',
cursor: 'pointer',
fontSize: '0.85rem',
};
const mono: React.CSSProperties = { fontFamily: 'ui-monospace, Menlo, Consolas, monospace', fontSize: '0.8rem' };
const statusColor: Record<Status, string> = { open: '#b45309', triaged: '#1d4ed8', closed: '#374151' };
function StatusBadge({ s }: { s: Status }) {
return (
<span style={{ background: statusColor[s], color: '#fff', borderRadius: '3px', padding: '0.05rem 0.4rem', fontSize: '0.72rem' }}>
{s}
</span>
);
}
const fmtTime = (ms: number) => new Date(ms).toLocaleString();
export default function Reports() {
const [app, setApp] = useState('');
const [status, setStatus] = useState<Status | ''>('open');
const [q, setQ] = useState('');
const [page, setPage] = useState<ListPage | null>(null);
const [older, setOlder] = useState<ReportSummary[]>([]);
const [more, setMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const query = useCallback(
(before?: number) => {
const p = new URLSearchParams();
if (app) p.set('app', app);
if (status) p.set('status', status);
if (q.trim()) p.set('q', q.trim());
if (before) p.set('before', String(before));
return `/api/reports?${p}`;
},
[app, status, q]
);
// The newest page is polled; pages loaded with "older" are kept beneath it
// until a filter changes, and then they own the "more" flag.
const olderCount = useRef(0);
olderCount.current = older.length;
const refresh = useCallback(async () => {
try {
const data = await getJSON<ListPage>(query());
setPage(data);
if (olderCount.current === 0) setMore(data.more);
setError(null);
} catch (e) {
setError(String((e as Error).message || e));
}
}, [query]);
useEffect(() => {
setOlder([]);
setMore(false);
}, [app, status, q]);
useEffect(() => {
refresh();
const t = setInterval(refresh, 10000);
return () => clearInterval(t);
}, [refresh]);
const loadOlder = async () => {
const all = [...(page?.reports || []), ...older];
const last = all[all.length - 1];
if (!last) return;
try {
const data = await getJSON<ListPage>(query(last.id));
setOlder((prev) => [...prev, ...data.reports]);
setMore(data.more);
} catch (e) {
setError(String((e as Error).message || e));
}
};
const rows = [...(page?.reports || []), ...older.filter((o) => !(page?.reports || []).some((r) => r.id === o.id))];
const apps = Object.entries(page?.apps || {}).sort((a, b) => a[0].localeCompare(b[0]));
const total = Object.values(page?.counts || {}).reduce((n, c) => n + c, 0);
return (
<div style={{ padding: '1rem', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexShrink: 0, flexWrap: 'wrap' }}>
<h1 style={{ margin: '0 0 0.75rem 0' }}>Reports</h1>
<span style={{ color: '#888', fontSize: '0.9rem' }}>bug reports and feedback filed from the apps' 🐛 buttons</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem', flexShrink: 0 }}>
<span style={chip(status === '')} onClick={() => setStatus('')}>all {total}</span>
{STATUSES.map((s) => (
<span key={s} style={chip(status === s)} onClick={() => setStatus(s)}>
{s} {page?.counts?.[s] ?? 0}
</span>
))}
<span style={{ width: '1rem' }} />
<span style={chip(app === '')} onClick={() => setApp('')}>every app</span>
{apps.map(([a, n]) => (
<span key={a} style={chip(app === a)} onClick={() => setApp(a)}>
{a} {n}
</span>
))}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="search note, title, cid, uid"
style={{ marginLeft: 'auto', padding: '0.35rem 0.6rem', borderRadius: '4px', border: '1px solid #0f3460', background: '#0b1630', color: '#eee', minWidth: '16rem' }}
/>
</div>
{error && <div style={{ color: '#f87171', marginBottom: '0.5rem', flexShrink: 0 }}>Could not load reports: {error}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(22rem, 2fr) 3fr', gap: '1rem', flex: 1, minHeight: 0 }}>
<div style={card}>
{rows.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>{page ? 'No reports match.' : 'Loading…'}</p>
) : (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{rows.map((r) => (
<div
key={r.id}
onClick={() => setSelected(r.id)}
style={{
padding: '0.55rem 0.5rem',
borderBottom: '1px solid #0f3460',
cursor: 'pointer',
background: selected === r.id ? '#0f3460' : 'transparent',
}}
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', fontSize: '0.8rem', color: '#888' }}>
<span style={mono}>#{r.id}</span>
<StatusBadge s={r.status} />
<span>{r.app}</span>
<span style={{ marginLeft: 'auto' }}>{fmtTime(r.ts)}</span>
</div>
<div style={{ margin: '0.2rem 0', color: '#eee' }}>{subject(r)}</div>
<div style={{ fontSize: '0.85rem', color: r.note ? '#ddd' : '#666' }}>
{r.note ? `“${r.note.length > 120 ? r.note.slice(0, 119) + '…' : r.note}”` : 'no note'}
</div>
<div style={{ ...mono, color: '#666', marginTop: '0.15rem' }}>
{buildLabel(r) || 'build unknown'}
{r.uid ? ` · ${shortCid(r.uid)}` : ''}
{r.log_lines ? ` · ${r.log_lines} log lines` : ''}
</div>
</div>
))}
{more && (
<div style={{ padding: '0.75rem', textAlign: 'center' }}>
<button style={button} onClick={loadOlder}>Load older</button>
</div>
)}
</div>
)}
</div>
<div style={card}>
{selected == null ? (
<p style={{ color: '#888', margin: 0 }}>Pick a report to read it.</p>
) : (
<ReportDetail
key={selected}
id={selected}
onChanged={refresh}
onDeleted={() => {
setSelected(null);
setOlder((prev) => prev.filter((r) => r.id !== selected));
refresh();
}}
/>
)}
</div>
</div>
</div>
);
}
function ReportDetail({ id, onChanged, onDeleted }: { id: number; onChanged: () => void; onDeleted: () => void }) {
const [r, setR] = useState<Report | null>(null);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<number>();
useEffect(() => {
getJSON<Report>(`/api/reports/${id}`)
.then((rep) => {
setR(rep);
setNote(rep.triage_note || '');
})
.catch((e) => setError(String(e.message || e)));
return () => window.clearTimeout(copyTimer.current);
}, [id]);
const patch = async (change: { status?: Status; triage_note?: string }) => {
setBusy(true);
try {
const rep = await getJSON<Report>(`/api/reports/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
});
setR(rep);
setNote(rep.triage_note || '');
setError(null);
onChanged();
} catch (e) {
setError(String((e as Error).message || e));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!window.confirm(`Delete report #${id}? This cannot be undone.`)) return;
setBusy(true);
try {
const res = await fetch(`/api/reports/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`/api/reports/${id} → HTTP ${res.status}`);
onDeleted();
} catch (e) {
setError(String((e as Error).message || e));
setBusy(false);
}
};
const copy = async () => {
if (!r) return;
try {
await navigator.clipboard.writeText(dump(r));
setCopied(true);
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setError('Clipboard unavailable here — select the text below and copy it by hand.');
}
};
if (error && !r) return <p style={{ color: '#f87171', margin: 0 }}>Could not load report #{id}: {error}</p>;
if (!r) return <p style={{ color: '#888', margin: 0 }}>Loading #{id}…</p>;
const body = r.body || {};
const bits = headline(r, body);
const device = deviceLine(body.device);
const log: string[] = Array.isArray(body.log) ? body.log : [];
return (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>#{r.id} · {subject(r, body)}</h2>
<StatusBadge s={r.status} />
<span style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
{STATUSES.filter((s) => s !== r.status).map((s) => (
<button key={s} style={button} disabled={busy} onClick={() => patch({ status: s })}>
Mark {s}
</button>
))}
<button style={button} onClick={copy}>{copied ? 'Copied' : 'Copy as text'}</button>
<button style={{ ...button, background: '#7f1d1d', borderColor: '#7f1d1d' }} disabled={busy} onClick={remove}>
Delete
</button>
</span>
</div>
{error && <div style={{ color: '#f87171', marginTop: '0.5rem' }}>{error}</div>}
<div style={{ color: '#888', fontSize: '0.85rem', margin: '0.4rem 0 0.75rem' }}>
{r.app} · filed {fmtTime(r.ts)}
{r.received - r.ts > 60000 ? ` · delivered ${fmtTime(r.received)}` : ''}
{r.uid ? ` · uid ${r.uid}` : ''}
</div>
{bits.length > 0 && (
<div style={{ marginBottom: '0.75rem', lineHeight: 1.6 }}>
{bits.map((b, i) => (
<span key={i}>
{i > 0 && <span style={{ color: '#555' }}> · </span>}
{b.strong ? <b style={{ color: '#fff' }}>{b.text}</b> : <span style={{ color: '#ccc' }}>{b.text}</span>}
</span>
))}
</div>
)}
<Field label="Page">
{r.page || '—'}
{r.route && <span style={{ ...mono, color: '#888' }}> {r.route}</span>}
</Field>
{(r.title || r.cid) && (
<Field label="Content">
{r.title}
{r.cid && <span style={{ ...mono, color: '#888' }}> {r.cid}</span>}
</Field>
)}
{device && <Field label="Device">{device}</Field>}
{r.build && (
<Field label="Build">
<pre style={{ ...mono, margin: 0, whiteSpace: 'pre-wrap', color: '#ccc' }}>{r.build}</pre>
</Field>
)}
<Field label="Triage">
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Deck #812, duplicate of #40, fixed in 1.0.95"
style={{ flex: 1, padding: '0.35rem 0.6rem', borderRadius: '4px', border: '1px solid #0f3460', background: '#0b1630', color: '#eee' }}
/>
<button style={button} disabled={busy || note === (r.triage_note || '')} onClick={() => patch({ triage_note: note })}>
Save
</button>
</div>
</Field>
{blocks(body)
.filter(([, v]) => v === null || typeof v !== 'object')
.map(([k, v]) => (
<Field key={k} label={k}>
<span style={mono}>{String(v)}</span>
</Field>
))}
{blocks(body).filter(([, v]) => v !== null && typeof v === 'object').map(([k, v]) => (
<details key={k} open={k === 'context' || k === 'reader'} style={{ marginTop: '0.6rem' }}>
<summary style={{ cursor: 'pointer', color: '#9ab' }}>{k}</summary>
<pre style={{ ...mono, background: '#0b1630', padding: '0.6rem', borderRadius: '4px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: '0.3rem 0 0' }}>
{typeof v === 'string' ? v : JSON.stringify(v, null, 2)}
</pre>
</details>
))}
{log.length > 0 && (
<details open style={{ marginTop: '0.6rem' }}>
<summary style={{ cursor: 'pointer', color: '#9ab' }}>log ({log.length} lines)</summary>
<pre style={{ ...mono, background: '#0b1630', padding: '0.6rem', borderRadius: '4px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: '0.3rem 0 0', maxHeight: '28rem', overflow: 'auto' }}>
{log.join('\n')}
</pre>
</details>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '5.5rem 1fr', gap: '0.5rem', margin: '0.3rem 0', alignItems: 'baseline' }}>
<span style={{ color: '#888', fontSize: '0.85rem' }}>{label}</span>
<div>{children}</div>
</div>
);
}