feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)

After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.

- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
  markdown in the user's language, the excluded rows, and its own fields to
  reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
  from the saved draft by app/issues/bundle.py — the one definition the Gitea
  delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
  review list draws a level the way both log viewers do
This commit is contained in:
2026-09-19 22:26:49 +02:00
parent 9f177557d2
commit 8d0083d4e4
23 changed files with 1620 additions and 35 deletions
+20
View File
@@ -54,6 +54,17 @@ export interface DebugSession {
/** The issue it was sent as, once sent. */
issue_url: string | null;
screenshots: number;
/** The issue being written from it (the composer saves as it goes); null until it first does. */
draft: DebugDraft | null;
}
/** A saved draft: the rendered text the export and the issue carry, plus the composer's own fields
* (`form`, lib/issueDraft `DraftForm`) to reopen it as it was. */
export interface DebugDraft {
title: string;
body: string;
excluded: number[];
form: Record<string, unknown>;
}
export interface DebugScreenshot {
@@ -87,6 +98,15 @@ export const debugSessionsApi = {
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
body: image,
}),
saveDebugDraft: (id: string, draft: DebugDraft): Promise<{ ok: true; session: DebugSession }> =>
req(`${SESSIONS}/${id}/draft`, { method: "PUT", body: JSON.stringify(draft) }),
/** The issue as a zip, built from the SAVED draft. A navigation, not a `req` — so the account
* rides as `?account=`, as for every download. */
debugExportUrl: (id: string): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/export`;
return a != null ? `${url}?account=${a}` : url;
},
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
@@ -38,6 +38,7 @@ const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
ended_reason: null,
issue_url: null,
screenshots: 0,
draft: null,
...over,
});
+128
View File
@@ -0,0 +1,128 @@
// E16 S3: the draft a session suggests, and the markdown an issue carries. Pure, so the composer's
// preview and what it saves are provably one computation.
import { describe, expect, it } from "vitest";
import type { ServerLogRow } from "./api";
import { draftSteps, osName, renderIssue, type DraftForm, type IssueText } from "./issueDraft";
let nextId = 1;
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
id: nextId++,
ts: "2026-09-19T14:05:31.000Z",
level: "INFO",
source: "web",
logger: "web",
event: "x.y",
msg: "",
attrs: null,
exc: null,
request_id: null,
user_id: 1,
job_id: null,
debug_session_id: "0123456789abcdef",
version: null,
...over,
});
const nav = (page: string) => row({ event: "nav.view", attrs: { via: "push", page } });
const step = (label?: string) =>
row({ event: "debug.step", attrs: label ? { n: 1, label } : { n: 1 } });
const pageName = (p: string) => p.toUpperCase();
const clock = () => "14:05:31";
describe("the steps a session suggests", () => {
it("are its markers and the page changes between them, in order", () => {
const steps = draftSteps(
[nav("plex"), nav("plex"), step("opened the player"), nav("settings")],
pageName,
clock,
);
expect(steps.map((s) => [s.kind, s.text])).toEqual([
["nav", "PLEX"], // the same page twice in a row is one step
["marker", "opened the player"],
["nav", "SETTINGS"],
]);
});
it("name a marker set without a label by its time, so the issue keeps it", () => {
expect(draftSteps([step()], pageName, clock)[0]?.text).toBe("14:05:31");
});
it("hang a screenshot on the step before it, and leave server rows out", () => {
const steps = draftSteps(
[
step("spinner"),
row({ event: "debug.screenshot" }),
row({ source: "api", event: "nav.view" }),
],
pageName,
clock,
);
expect(steps).toHaveLength(1);
expect(steps[0]?.shot).toBe(true);
});
});
const TEXT: IssueText = {
steps: "Steps",
environment: "Environment",
envKeys: { version: "Version" },
screenshot: "screenshot",
attachments: "Attachments",
clientLog: (n) => `${n} browser lines`,
serverLog: (n) => `${n} server lines`,
screenshots: (n) => `${n} screenshots`,
};
const form = (over: Partial<DraftForm> = {}): DraftForm => ({
title: "t",
description: "It spins.",
steps: [
{ id: "a", kind: "nav", text: "Plex" },
{ id: "b", kind: "marker", text: " ", shot: false },
{ id: "c", kind: "marker", text: "Resume", shot: true },
],
excluded: [],
...over,
});
describe("the issue text", () => {
it("carries the description, the non-empty steps, the environment and the attachments", () => {
const md = renderIssue(
form(),
[["version", "0.93.0 | beta"]],
{ client: 3, server: 0, screenshots: 1 },
TEXT,
);
expect(md).toBe(
[
"It spins.",
"### Steps\n\n1. Plex\n2. Resume _(screenshot)_",
"### Environment\n\n| | |\n|---|---|\n| Version | 0.93.0 \\| beta |",
"### Attachments\n\n- `client.log` — 3 browser lines\n- 1 screenshots",
].join("\n\n"),
);
});
it("says when the logs were cut", () => {
const md = renderIssue(
form(),
[],
{ client: 1, server: 1, screenshots: 0 },
{
...TEXT,
truncated: "cut at 5000",
},
);
expect(md.endsWith("_cut at 5000_")).toBe(true);
});
});
describe("the system a browser runs on", () => {
it.each([
["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/152.0.0.0 Mobile", "Android 10"],
["Mozilla/5.0 (iPad; CPU OS 26_6_1 like Mac OS X) AppleWebKit/605.1.15", "iPad 26.6.1"],
["Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)", "iPhone 18.2"],
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0", "Windows NT 10.0"],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15", "macOS 10.15.7"],
["Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0", "Linux"],
])("%s → %s", (ua, os) => expect(osName(ua)).toBe(os));
});
+147
View File
@@ -0,0 +1,147 @@
// The issue drafted from a debug session (E16 S3) — pure functions, so what the composer shows and
// what it saves are one computation, testable without a browser.
//
// The composer renders the markdown HERE, in the user's language, and the server carries it as it
// was reviewed (backend app/issues/bundle.py): the server adds no sentence of its own.
import type { ServerLogRow } from "./api";
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
/** Where a step came from: a marker the user set, a page change, or typed in the composer. */
type StepKind = "marker" | "nav" | "manual";
export interface DraftStep {
id: string;
kind: StepKind;
text: string;
/** A screenshot was taken at this step. */
shot?: boolean;
}
/** What the composer edits and saves (`form` in the saved draft). */
export interface DraftForm {
title: string;
description: string;
steps: DraftStep[];
/** Log row ids taken out of the evidence. */
excluded: number[];
}
/** The steps a session suggests: every marker the user set, and the page changes between them. A
* page change repeated back to back is one step; a screenshot belongs to the step before it. A
* marker set without a label is named by its time (`clock`) — the one thing that finds it in the
* log — rather than left empty, where the issue would drop it. */
export function draftSteps(
rows: readonly ServerLogRow[],
pageName: (page: string) => string,
clock: (ts: string) => string,
) {
const steps: DraftStep[] = [];
for (const row of rows) {
if (row.source !== "web") continue;
const attrs = row.attrs ?? {};
if (row.event === "debug.step") {
const label = typeof attrs.label === "string" ? attrs.label.trim() : "";
steps.push({ id: `r${row.id}`, kind: "marker", text: label || clock(row.ts) });
} else if (row.event === "nav.view") {
const page = typeof attrs.page === "string" ? attrs.page : String(attrs.path ?? "");
const text = page ? pageName(page) : "";
const last = steps[steps.length - 1];
if (!text || (last?.kind === "nav" && last.text === text)) continue;
steps.push({ id: `r${row.id}`, kind: "nav", text });
} else if (row.event === "debug.screenshot") {
const last = steps[steps.length - 1];
if (last) last.shot = true;
}
}
return steps;
}
/** The operating system a user agent names. Checked most specific first: Android's agent also says
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */
export function osName(ua: string): string {
const tests: [RegExp, (m: RegExpExecArray) => string][] = [
[/Android [\d.]+/, (m) => m[0]],
[/(iPhone|iPad)[^)]*? OS ([\d_]+)/, (m) => `${m[1]} ${m[2]!.replace(/_/g, ".")}`],
[/Windows NT [\d.]+/, (m) => m[0]],
[/Mac OS X ([\d_]+)/, (m) => `macOS ${m[1]!.replace(/_/g, ".")}`],
[/CrOS|Linux/, (m) => m[0]],
];
for (const [re, name] of tests) {
const m = re.exec(ua);
if (m) return name(m);
}
return "?";
}
/** The environment table: what a developer asks first. Read from this browser at composing time —
* the same browser the session recorded in, or the report says which one it came from. */
export function environment(language: string): [key: string, value: string][] {
const ua = navigator.userAgent;
const browser =
/Edg\/([\d.]+)/.exec(ua)?.[0] ??
/Firefox\/([\d.]+)/.exec(ua)?.[0] ??
/Chrome\/([\d.]+)/.exec(ua)?.[0] ??
/Version\/([\d.]+).*Safari/.exec(ua)?.[0] ??
ua;
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return [
["version", `${FRONTEND_VERSION} (${FRONTEND_SHA})`],
["browser", browser.replace("/", " ")],
["os", osName(ua)],
[
"window",
`${window.innerWidth} × ${window.innerHeight} · ${window.devicePixelRatio}x · ${Math.round((rootPx / 16) * 100)}%`,
],
["language", language],
];
}
/** A table cell: a pipe or a line break would break the markdown table. */
const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
export interface IssueText {
steps: string;
environment: string;
envKeys: Record<string, string>;
screenshot: string;
attachments: string;
clientLog: (n: number) => string;
serverLog: (n: number) => string;
screenshots: (n: number) => string;
/** Shown when the evidence was cut at the server's cap. */
truncated?: string;
}
/** The issue body as markdown: the description, the steps, the environment, the attachments. */
export function renderIssue(
form: DraftForm,
env: readonly [string, string][],
counts: { client: number; server: number; screenshots: number },
text: IssueText,
): string {
const parts: string[] = [];
const description = form.description.trim();
if (description) parts.push(description);
const steps = form.steps.filter((s) => s.text.trim());
if (steps.length)
parts.push(
`### ${text.steps}\n\n` +
steps
.map((s, i) => `${i + 1}. ${s.text.trim()}${s.shot ? ` _(${text.screenshot})_` : ""}`)
.join("\n"),
);
parts.push(
`### ${text.environment}\n\n| | |\n|---|---|\n` +
env.map(([k, v]) => `| ${cell(text.envKeys[k] ?? k)} | ${cell(v)} |`).join("\n"),
);
const attachments = [
counts.client ? `- \`client.log\` — ${text.clientLog(counts.client)}` : "",
counts.server ? `- \`server.log\` — ${text.serverLog(counts.server)}` : "",
counts.screenshots ? `- ${text.screenshots(counts.screenshots)}` : "",
].filter(Boolean);
if (attachments.length) {
const cut = text.truncated ? `\n\n_${text.truncated}_` : "";
parts.push(`### ${text.attachments}\n\n${attachments.join("\n")}${cut}`);
}
return parts.join("\n\n");
}
+5
View File
@@ -95,6 +95,11 @@ export const qk = {
adminLogs: (filters: object) => ["admin-logs", filters] as const,
adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const,
// --- debug sessions (E16) ---
debugSessions: () => ["debug-sessions"] as const,
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const,