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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user