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),