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
Build and Push Docker Image / build (push) Failing after 1m20s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user