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