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