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
@@ -35,6 +35,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
ended_reason: null,
issue_url: null,
screenshots: 0,
draft: null,
...over,
});
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
+72 -4
View File
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import type { DebugSessionLevel } from "../lib/api";
import { api, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
openComposer,
openRecorderStart,
useComposerSession,
useRecorderStartOpen,
useStepCount,
} from "../lib/debugRecorder";
@@ -19,8 +20,10 @@ import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
// The debug recorder (E16): Start → reproduce → Stop.
@@ -47,6 +50,7 @@ export default function DebugRecorder() {
const me = useMe();
const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen();
const composing = useComposerSession();
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
@@ -66,7 +70,12 @@ export default function DebugRecorder() {
return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]);
return startOpen ? <StartDialog /> : null;
return (
<>
{startOpen && <StartDialog />}
{composing && <IssueComposer id={composing} />}
</>
);
}
function StartDialog() {
@@ -216,8 +225,17 @@ function Elapsed({ since }: { since: string }) {
);
}
/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
export function RecorderSettings() {
return (
<div className="flex flex-col gap-4">
<RecorderControl />
<SessionHistory />
</div>
);
}
function RecorderControl() {
const { t } = useTranslation();
const { session, stop } = useDebugRecording();
if (!session)
@@ -294,3 +312,53 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
</div>
);
}
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
return (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.history.failed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
return (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
{items.length === 0 ? (
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
) : (
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
{items.map((s) => (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
<span className={`chip px-2 py-0.5 text-2xs ${s.issue_url ? "text-success" : ""}`}>
{t(
s.issue_url
? "recorder.history.sent"
: s.draft
? "recorder.history.draft"
: "recorder.history.unwritten",
)}
</span>
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
{t("recorder.history.open")}
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,225 @@
// @vitest-environment jsdom
//
// E16 S3: the issue composer. It drafts the steps from the session, saves as it goes (the rendered
// text included — the export is built from what it SAVED), reopens as it was left, takes a log line
// out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a
// key-passthrough with its params; the API is stubbed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
i18n: { language: "en" },
}),
};
});
const api = vi.hoisted(() => ({
session: null as unknown as DebugSession,
rows: [] as ServerLogRow[],
saved: [] as DebugDraft[],
uploaded: [] as Blob[],
failSession: false,
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
debugSession: () =>
api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session),
debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }),
debugScreenshots: () => Promise.resolve({ items: [] }),
saveDebugDraft: (_id: string, draft: DebugDraft) => {
api.saved.push(draft);
return Promise.resolve({ ok: true, session: { ...api.session, draft } });
},
addDebugScreenshot: (_id: string, image: Blob) => {
api.uploaded.push(image);
return Promise.resolve({
ok: true,
screenshot: { id: 1, mime: image.type, size: 4, created_at: "" },
});
},
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
},
};
});
import IssueComposer from "./IssueComposer";
const SID = "0123456789abcdef";
let n = 1;
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
id: n++,
ts: "2026-09-19T14:05:31.000Z",
level: "INFO",
source: "web",
logger: "web",
event: "x.y",
msg: "m",
attrs: null,
exc: null,
request_id: null,
user_id: 1,
job_id: null,
debug_session_id: SID,
version: null,
...over,
});
const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
id: SID,
level: "DEBUG",
started_at: "2026-09-19T14:02:00.000Z",
until: "2026-09-19T15:02:00.000Z",
ended_at: "2026-09-19T14:16:00.000Z",
ended_reason: "stopped",
issue_url: null,
screenshots: 0,
draft: null,
...over,
});
beforeEach(() => {
api.session = baseSession();
api.rows = [
row({ event: "debug.step", msg: "resume", attrs: { n: 1, label: "Resume" } }),
row({ event: "ui.click", msg: "private click" }),
row({ source: "api", event: "http.request", msg: "GET /api/x 200" }),
];
api.saved = [];
api.uploaded = [];
api.failSession = false;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<IssueComposer id={SID} />
</QueryClientProvider>,
);
}
const lastSaved = () => api.saved[api.saved.length - 1]!;
describe("the issue composer", () => {
it("drafts the steps from the session and saves the rendered text after a pause", async () => {
mount();
const first = await screen.findByLabelText("recorder.composer.stepLabel:1");
expect((first as HTMLInputElement).value).toBe("Resume");
fireEvent.change(screen.getByLabelText("recorder.composer.fieldTitle"), {
target: { value: "Spinner stays" },
});
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
expect(lastSaved().title).toBe("Spinner stays");
expect(lastSaved().body).toContain("1. Resume");
expect(lastSaved().body).toContain("`client.log` — recorder.issue.clientLog:2");
});
it("reopens a saved draft as it was left", async () => {
api.session = baseSession({
draft: {
title: "Saved title",
body: "",
excluded: [],
form: {
title: "Saved title",
description: "kept",
steps: [{ id: "m1", kind: "manual", text: "typed" }],
excluded: [],
},
},
});
mount();
expect(
((await screen.findByLabelText("recorder.composer.fieldTitle")) as HTMLInputElement).value,
).toBe("Saved title");
expect((screen.getByLabelText("recorder.composer.stepLabel:1") as HTMLInputElement).value).toBe(
"typed",
);
});
it("takes an unticked line out of the evidence", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
fireEvent.click(
screen.getByRole("checkbox", { name: "recorder.composer.keepRow:ui.click,private click" }),
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" }));
expect(screen.getByText(/recorder.composer.linesKept:1,2/)).not.toBeNull();
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
const clickId = api.rows[1]!.id;
expect(lastSaved().excluded).toEqual([clickId]);
expect(lastSaved().body).toContain("recorder.issue.clientLog:1");
});
it("adds a pasted image, and nothing else that was pasted", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
const text = new File(["hi"], "note.txt", { type: "text/plain" });
fireEvent.paste(zone, { clipboardData: { files: [png, text] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("takes a dropped or picked image, and nothing else", async () => {
mount();
const zone = (await screen.findByText(/recorder.composer.drop/)).closest("div")!;
const png = new File([new Uint8Array([137, 80, 78, 71])], "a.png", { type: "image/png" });
const pdf = new File(["%PDF"], "a.pdf", { type: "application/pdf" });
fireEvent.drop(zone, { dataTransfer: { files: [pdf, png] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("leaves a paste without an image to the field it was typed into", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
const pasted = fireEvent.paste(zone, { clipboardData: { files: [] } });
expect(pasted).toBe(true); // not prevented
expect(api.uploaded).toHaveLength(0);
});
it("saves before it downloads the zip", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.export" }));
await waitFor(() => expect(click).toHaveBeenCalled());
expect(api.saved).toHaveLength(1);
click.mockRestore();
});
it("a sent session is read-only and links its issue", async () => {
api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7" });
mount();
const link = await screen.findByRole("link", { name: /recorder.composer.openIssue/ });
expect(link.getAttribute("href")).toBe("https://git.example/o/r/issues/7");
const title = screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
expect(title.readOnly).toBe(true);
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
});
it("says so when the session cannot be loaded, with a retry", async () => {
api.failSession = true;
mount();
const message = await screen.findByText("recorder.composer.loadFailed");
expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull();
});
});
+565
View File
@@ -0,0 +1,565 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import {
draftSteps,
environment,
renderIssue,
type DraftForm,
type DraftStep,
type IssueText,
} from "../lib/issueDraft";
import { moduleDef } from "../lib/modules";
import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { inputCls } from "./ui/form";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
// to add more). It saves as it goes, so closing it loses nothing: Settings → Diagnosztika reopens
// any past session. The export and (S4) the issue are built from what it SAVED.
const SAVE_DELAY_MS = 800;
type Source = "web" | "server";
const sourceOf = (row: ServerLogRow): Source => (row.source === "web" ? "web" : "server");
export default function IssueComposer({ id }: { id: string }) {
const { t } = useTranslation();
const sessionQ = useQuery({ queryKey: qk.debugSession(id), queryFn: () => api.debugSession(id) });
const eventsQ = useQuery({
queryKey: qk.debugSessionEvents(id),
queryFn: () => api.debugSessionEvents(id),
});
const failed = sessionQ.isError || eventsQ.isError;
return (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
{failed && !(sessionQ.data && eventsQ.data) ? (
<StateMessage
tone="error"
onRetry={() => {
void sessionQ.refetch();
void eventsQ.refetch();
}}
>
{t("recorder.composer.loadFailed")}
</StateMessage>
) : !sessionQ.data || !eventsQ.data ? (
<LoadingState />
) : (
<ComposerBody
session={sessionQ.data}
rows={eventsQ.data.items}
total={eventsQ.data.total}
/>
)}
</Modal>
);
}
/** The saved form if it has the shape this composer writes; otherwise null (an older or foreign
* draft starts over from the session rather than half-applying). */
function savedForm(session: DebugSession): DraftForm | null {
const form = session.draft?.form as Partial<DraftForm> | undefined;
if (!form || typeof form.title !== "string" || !Array.isArray(form.steps)) return null;
return {
title: form.title,
description: typeof form.description === "string" ? form.description : "",
steps: form.steps as DraftStep[],
excluded: Array.isArray(form.excluded) ? (form.excluded as number[]) : [],
};
}
function ComposerBody({
session,
rows,
total,
}: {
session: DebugSession;
rows: ServerLogRow[];
total: number;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const pageName = (page: string) => {
const def = moduleDef(page as Page);
return def ? t(def.labelKey) : page;
};
const [form, setForm] = useState<DraftForm>(
() =>
savedForm(session) ?? {
title: "",
description: "",
steps: draftSteps(rows, pageName, logClock),
excluded: [],
},
);
const [reviewing, setReviewing] = useState<Source | null>(null);
const sent = session.issue_url !== null;
const shotsQ = useQuery({
queryKey: qk.debugScreenshots(session.id),
queryFn: () => api.debugScreenshots(session.id),
});
const shots = shotsQ.data?.items ?? [];
const excluded = useMemo(() => new Set(form.excluded), [form.excluded]);
const kept = (source: Source) =>
rows.filter((r) => sourceOf(r) === source && !excluded.has(r.id)).length;
const env = useMemo(() => environment(i18n.language), [i18n.language]);
const text: IssueText = {
steps: t("recorder.issue.steps"),
environment: t("recorder.issue.environment"),
envKeys: Object.fromEntries(env.map(([k]) => [k, t(`recorder.composer.env.${k}`)])),
screenshot: t("recorder.issue.screenshot"),
attachments: t("recorder.issue.attachments"),
clientLog: (count) => t("recorder.issue.clientLog", { count }),
serverLog: (count) => t("recorder.issue.serverLog", { count }),
screenshots: (count) => t("recorder.issue.screenshots", { count }),
truncated:
total > rows.length
? t("recorder.composer.truncated", { kept: rows.length, total })
: undefined,
};
const body = renderIssue(
form,
env,
{ client: kept("web"), server: kept("server"), screenshots: shots.length },
text,
);
// Saves as it goes — after the typing pauses, not on every key.
const save = useMutation({
mutationFn: () =>
api.saveDebugDraft(session.id, {
title: form.title.trim(),
body,
excluded: form.excluded,
form: form as unknown as Record<string, unknown>,
}),
meta: { failTitle: "recorder.composer.saveFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
},
});
const dirty = useRef(false);
const saveRef = useRef(save.mutate);
saveRef.current = save.mutate;
useEffect(() => {
if (sent || !dirty.current) return;
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, sent]);
const edit = (next: (f: DraftForm) => DraftForm) => {
dirty.current = true;
setForm(next);
};
// The export is built from the SAVED draft: save what is on screen first.
const exportZip = async () => {
if (!sent) await save.mutateAsync();
const a = document.createElement("a");
a.href = api.debugExportUrl(session.id);
a.download = "";
a.click();
};
const started = new Date(session.started_at);
const minutes = Math.max(
1,
Math.round(
(Date.parse(session.ended_at ?? new Date().toISOString()) - started.getTime()) / 60_000,
),
);
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: started.toLocaleString(i18n.language),
minutes,
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
{sent && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
<span>{t("recorder.composer.sent")}</span>
<a
href={session.issue_url!}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline"
>
{t("recorder.composer.openIssue")}
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
</a>
</div>
)}
{reviewing ? (
<ReviewList
rows={rows.filter((r) => sourceOf(r) === reviewing)}
name={t(
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
)}
excluded={excluded}
readOnly={sent}
onToggle={(rowId) =>
edit((f) => ({
...f,
excluded: f.excluded.includes(rowId)
? f.excluded.filter((x) => x !== rowId)
: [...f.excluded, rowId],
}))
}
onDone={() => setReviewing(null)}
/>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<DraftFields form={form} edit={edit} env={env} readOnly={sent} />
<Attachments
session={session}
shots={shots}
counts={{
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
}}
readOnly={sent}
onReview={setReviewing}
/>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<span className="mr-auto text-xs text-muted" aria-live="polite">
{save.isPending
? t("recorder.composer.saving")
: save.isSuccess
? t("recorder.composer.saved")
: ""}
</span>
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
</Button>
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
{t("recorder.composer.export")}
</Button>
</div>
</div>
);
}
function DraftFields({
form,
edit,
env,
readOnly,
}: {
form: DraftForm;
edit: (next: (f: DraftForm) => DraftForm) => void;
env: [string, string][];
readOnly: boolean;
}) {
const { t } = useTranslation();
const setStep = (i: number, patch: Partial<DraftStep>) =>
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
return (
<div className="flex flex-col gap-4 min-w-0">
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-title" className="text-sm text-muted">
{t("recorder.composer.fieldTitle")}
</label>
<input
id="issue-title"
value={form.title}
readOnly={readOnly}
maxLength={200}
placeholder={t("recorder.composer.titlePlaceholder")}
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-description" className="text-sm text-muted">
{t("recorder.composer.description")}
</label>
<textarea
id="issue-description"
value={form.description}
readOnly={readOnly}
rows={4}
placeholder={t("recorder.composer.descriptionPlaceholder")}
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
className={`${inputCls} resize-y`}
/>
</div>
<fieldset className="flex flex-col gap-1.5 min-w-0">
<legend className="mb-1.5 text-sm text-muted">{t("recorder.composer.steps")}</legend>
<ol className="flex flex-col gap-1.5">
{form.steps.map((step, i) => (
<li
key={step.id}
className="flex items-center gap-2 rounded-lg bg-surface/60 pl-2.5 pr-1 py-1"
>
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
{i + 1}.
</span>
<input
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
value={step.text}
readOnly={readOnly}
placeholder={t("recorder.composer.stepPlaceholder")}
onChange={(e) => setStep(i, { text: e.target.value })}
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
/>
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
{t(`recorder.composer.stepKind.${step.kind}`)}
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
</span>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeStep", { n: i + 1 })}
onClick={() => edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }))}
/>
)}
</li>
))}
</ol>
{!readOnly && (
<Button
variant="link"
className="self-start"
icon={<Plus className="w-4 h-4" />}
onClick={() =>
edit((f) => ({
...f,
steps: [...f.steps, { id: `m${Date.now()}`, kind: "manual", text: "" }],
}))
}
>
{t("recorder.composer.addStep")}
</Button>
)}
</fieldset>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.composer.environment")}</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm">
{env.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{t(`recorder.composer.env.${k}`)}</dt>
<dd className="break-words tabular-nums">{v}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
function Attachments({
session,
shots,
counts,
readOnly,
onReview,
}: {
session: DebugSession;
shots: DebugScreenshot[];
counts: Record<Source, [kept: number, total: number]>;
readOnly: boolean;
onReview: (source: Source) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(session.id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id) });
};
const add = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const addFiles = (files: Iterable<File>) => {
for (const file of files) if (IMAGE_TYPES.includes(file.type)) add.mutate(file);
};
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...e.clipboardData.files].filter((f) => IMAGE_TYPES.includes(f.type));
if (!images.length) return;
e.preventDefault();
for (const image of images) add.mutate(image);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
};
const logRow = (source: Source, icon: React.ReactNode) => {
const [kept, total] = counts[source];
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
{icon}
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">
{t(source === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog")}
</span>
<span className="text-xs text-muted tabular-nums">
{kept === total
? t("recorder.composer.lines", { count: total })
: t("recorder.composer.linesKept", { kept, total })}
{` · ${source === "web" ? "client.log" : "server.log"}`}
</span>
</div>
{total > 0 && (
<Button variant="outline" size="sm" onClick={() => onReview(source)}>
{t("recorder.composer.review")}
</Button>
)}
</div>
);
};
return (
<div
className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"
onPaste={readOnly ? undefined : onPaste}
>
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{shots.map((shot, i) => (
<div
key={shot.id}
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
>
<img
src={api.debugScreenshotUrl(session.id, shot.id)}
alt={t("recorder.composer.screenshot", { n: i + 1 })}
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">
{Math.round(shot.size / 1024)} KB
</span>
</div>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeShot", { n: i + 1 })}
loading={remove.isPending && remove.variables === shot.id}
onClick={() => remove.mutate(shot.id)}
/>
)}
</div>
))}
{!readOnly && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
>
{t("recorder.composer.drop")}{" "}
<label className="cursor-pointer font-semibold text-accent hover:underline">
{t("recorder.composer.pick")}
<input
type="file"
accept={IMAGE_TYPES.join(",")}
multiple
className="sr-only"
onChange={(e) => {
addFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
</label>
</div>
)}
</div>
);
}
/** One log, line by line: an unticked line is not sent. */
function ReviewList({
rows,
name,
excluded,
readOnly,
onToggle,
onDone,
}: {
rows: ServerLogRow[];
name: string;
excluded: Set<number>;
readOnly: boolean;
onToggle: (id: number) => void;
onDone: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
</div>
<Button variant="primary" size="sm" onClick={onDone}>
{t("recorder.composer.reviewDone")}
</Button>
</div>
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
{rows.map((row) => {
const tag = LEVEL_TAG[row.level];
const off = excluded.has(row.id);
return (
<li key={row.id} className="border-b border-border/50 last:border-b-0">
<label
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
off ? "opacity-50 line-through" : ""
}`}
>
<input
type="checkbox"
checked={!off}
disabled={readOnly}
aria-label={t("recorder.composer.keepRow", {
event: row.event ?? "",
msg: row.msg,
})}
onChange={() => onToggle(row.id)}
/>
<span className="tabular-nums text-muted">{logClock(row.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{row.event}</span>
<span className="col-span-4 truncate sm:col-span-1">{row.msg}</span>
</label>
</li>
);
})}
</ul>
</div>
);
}
+1 -16
View File
@@ -2,28 +2,13 @@ import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
import { LEVEL_TAG, logClock } from "./logLineParts";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both.
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
export default function LogLine({
ts,
level,
+21
View File
@@ -0,0 +1,21 @@
import type { Level } from "../lib/log";
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
// LogLine.tsx because a component file exports only components (react-refresh).
export const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
export function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { updateMeCache, useMe } from "../lib/useMe";
/** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings →
@@ -18,8 +19,10 @@ export function useDebugRecording() {
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) =>
const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions() });
};
const start = useMutation({
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
@@ -43,5 +43,77 @@
"start": "Start a debug recording",
"step": "Mark a step in the recording",
"stop": "Stop the debug recording"
},
"composer": {
"title": "Bug report",
"meta": "{{start}} · {{minutes}} min · {{level}}",
"fieldTitle": "Title",
"titlePlaceholder": "What went wrong?",
"description": "Description",
"descriptionPlaceholder": "What did you expect, and what happened instead?",
"steps": "Steps — from your markers and page changes, editable",
"stepKind": {
"marker": "marker",
"nav": "page",
"manual": "added"
},
"stepLabel": "Step {{n}}",
"stepPlaceholder": "What did you do?",
"withShot": "image",
"addStep": "Add a step",
"removeStep": "Remove step {{n}}",
"environment": "Environment — automatic",
"attachments": "Attachments — what is sent, each one removable",
"clientLog": "Browser log",
"serverLog": "Server log",
"lines_one": "{{count}} line",
"lines_other": "{{count}} lines",
"linesKept": "{{kept}} of {{total}} lines",
"review": "Review",
"reviewTitle": "{{name}} — review",
"reviewHint": "An unticked line is not sent.",
"reviewDone": "Done",
"keepRow": "Keep line: {{event}} {{msg}}",
"screenshot": "Screenshot {{n}}",
"removeShot": "Remove screenshot {{n}}",
"drop": "Paste an image with Ctrl+V, drop it here, or",
"pick": "choose a file",
"close": "Close",
"export": "Download as zip",
"saving": "Saving…",
"saved": "Draft saved",
"saveFailed": "Couldn't save the draft",
"loadFailed": "Couldn't load the session",
"truncated": "The logs carry the first {{kept}} of {{total}} lines.",
"sent": "Sent as an issue.",
"openIssue": "Open the issue",
"env": {
"version": "Version",
"browser": "Browser",
"os": "System",
"window": "Window",
"language": "Language"
}
},
"issue": {
"steps": "Steps",
"environment": "Environment",
"screenshot": "screenshot",
"attachments": "Attachments",
"clientLog_one": "{{count}} line from the browser",
"clientLog_other": "{{count}} lines from the browser",
"serverLog_one": "{{count}} line from the server",
"serverLog_other": "{{count}} lines from the server",
"screenshots_one": "{{count}} screenshot",
"screenshots_other": "{{count}} screenshots"
},
"history": {
"title": "Past sessions",
"empty": "No session recorded yet.",
"open": "Open",
"sent": "Sent",
"draft": "Draft",
"unwritten": "Not written yet",
"failed": "Couldn't load the sessions"
}
}
@@ -43,5 +43,77 @@
"start": "Hibakereső rögzítés indítása",
"step": "Lépés megjelölése a rögzítésben",
"stop": "Hibakereső rögzítés leállítása"
},
"composer": {
"title": "Hibajegy",
"meta": "{{start}} · {{minutes}} perc · {{level}}",
"fieldTitle": "Cím",
"titlePlaceholder": "Mi romlott el?",
"description": "Leírás",
"descriptionPlaceholder": "Mit vártál, és mi történt helyette?",
"steps": "Lépések — a jelölőidből és az oldalváltásokból, szerkeszthető",
"stepKind": {
"marker": "jelölő",
"nav": "oldalváltás",
"manual": "saját"
},
"stepLabel": "{{n}}. lépés",
"stepPlaceholder": "Mit csináltál?",
"withShot": "kép",
"addStep": "Lépés hozzáadása",
"removeStep": "{{n}}. lépés törlése",
"environment": "Környezet — automatikus",
"attachments": "Csatolmányok — ami elmegy, egyenként kivehető",
"clientLog": "Böngészőnapló",
"serverLog": "Szervernapló",
"lines_one": "{{count}} sor",
"lines_other": "{{count}} sor",
"linesKept": "{{kept}} / {{total}} sor",
"review": "Átnézés",
"reviewTitle": "{{name}} — átnézés",
"reviewHint": "Pipa nélküli sor nem megy el.",
"reviewDone": "Kész",
"keepRow": "Sor megtartása: {{event}} {{msg}}",
"screenshot": "{{n}}. képernyőkép",
"removeShot": "{{n}}. képernyőkép kivétele",
"drop": "Kép beillesztése: Ctrl+V, vagy húzd ide, vagy",
"pick": "válassz fájlt",
"close": "Bezárás",
"export": "Letöltés zipként",
"saving": "Mentés…",
"saved": "Vázlat mentve",
"saveFailed": "Nem sikerült menteni a vázlatot",
"loadFailed": "Nem sikerült betölteni a munkamenetet",
"truncated": "A napló az első {{kept}} sort tartalmazza, összesen {{total}} volt.",
"sent": "Elküldve hibajegyként.",
"openIssue": "Hibajegy megnyitása",
"env": {
"version": "Verzió",
"browser": "Böngésző",
"os": "Rendszer",
"window": "Ablak",
"language": "Nyelv"
}
},
"issue": {
"steps": "Lépések",
"environment": "Környezet",
"screenshot": "képernyőkép",
"attachments": "Csatolmányok",
"clientLog_one": "{{count}} sor a böngészőből",
"clientLog_other": "{{count}} sor a böngészőből",
"serverLog_one": "{{count}} sor a szerverről",
"serverLog_other": "{{count}} sor a szerverről",
"screenshots_one": "{{count}} képernyőkép",
"screenshots_other": "{{count}} képernyőkép"
},
"history": {
"title": "Korábbi munkamenetek",
"empty": "Még nincs rögzített munkamenet.",
"open": "Megnyitás",
"sent": "Elküldve",
"draft": "Vázlat",
"unwritten": "Megírásra vár",
"failed": "Nem sikerült betölteni a munkameneteket"
}
}
+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,