feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)

- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
  retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
  its own Escape layer), Screenshot (tab capture where the browser has it),
  Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
  beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
  raises the level for the session's run and never lowers a raised one, and
  lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
  says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
  new modifier chord, and a bare letter collides with the players'
This commit is contained in:
2026-09-19 22:08:10 +02:00
parent 2d9595b0e5
commit 46fce4a767
26 changed files with 1347 additions and 89 deletions
+66 -1
View File
@@ -1,7 +1,8 @@
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import { beacon, req } from "./core";
import type { ServerLogRow } from "./admin";
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -12,6 +13,8 @@ export interface DiagnosticsState {
until: string | null;
/** How many days this instance keeps stored log lines — the consent text states it. */
retention_days: number;
/** How long a debug session records at most — the start dialog's consent text states it. */
session_minutes: number;
}
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
@@ -33,3 +36,65 @@ export const diagnosticsApi = {
/** The last batch of a closing tab (keepalive, fire-and-forget). */
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
};
// --- debug sessions (E16) ----------------------------------------------------------------------
/** The two levels a recording can run at — it always records more than the base. */
export type DebugSessionLevel = "DEBUG" | "TRACE";
/** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is
* over whether or not it was stopped. */
export interface DebugSession {
id: string;
level: DebugSessionLevel;
started_at: string;
until: string;
ended_at: string | null;
ended_reason: "stopped" | "expired" | null;
/** The issue it was sent as, once sent. */
issue_url: string | null;
screenshots: number;
}
export interface DebugScreenshot {
id: number;
mime: string;
size: number;
created_at: string;
}
const SESSIONS = "/api/debug-sessions";
export const debugSessionsApi = {
startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> =>
req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }),
stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> =>
req(`${SESSIONS}/${id}/stop`, { method: "POST" }),
debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS),
debugSession: (id: string): Promise<DebugSession> => req(`${SESSIONS}/${id}`),
/** The rows an issue from this session would carry, browser and server, in time order. */
debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> =>
req(`${SESSIONS}/${id}/events`),
debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> =>
req(`${SESSIONS}/${id}/screenshots`),
/** One image as the raw body — the server checks what it really is, whatever `type` says. */
addDebugScreenshot: (
id: string,
image: Blob,
): Promise<{ ok: true; screenshot: DebugScreenshot }> =>
req(`${SESSIONS}/${id}/screenshots`, {
method: "POST",
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
body: image,
}),
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
* per-tab account's screenshot resolves to the session default and 404s. */
debugScreenshotUrl: (id: string, shotId: number): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
return a != null ? `${url}?account=${a}` : url;
},
};