feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded, retention and the cap (now served as diagnostics.session_minutes) - the pill docks under the toasts: elapsed time, Step (an optional label, its own Escape layer), Screenshot (tab capture where the browser has it), Stop; compact on a phone - while recording, lib/log tags every event and every request path (req, beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics raises the level for the session's run and never lowers a raised one, and lapses at whichever ends first - Stop sends what is waiting before the server closes the session; the cap says so and opens the composer - Settings → Diagnosztika and the command palette start/step/stop it - the evidence leaves the log upload's own request lines out - the step shortcut is deliberately palette-only: the keymap's D1 rule bars a new modifier chord, and a bare letter collides with the players'
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, Flag, Square } from "lucide-react";
|
||||
|
||||
import type { DebugSessionLevel } from "../lib/api";
|
||||
import { canCaptureTab } from "../lib/captureTab";
|
||||
import {
|
||||
closeRecorderStart,
|
||||
openComposer,
|
||||
openRecorderStart,
|
||||
useRecorderStartOpen,
|
||||
useStepCount,
|
||||
} from "../lib/debugRecorder";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../lib/toastDock";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
//
|
||||
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
|
||||
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
|
||||
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
|
||||
// app shell, for a signed-in account other than the demo.
|
||||
|
||||
/** `m:ss` since `since`, re-rendered every second. */
|
||||
function useElapsed(since: string): string {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const s = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function DebugRecorder() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const { session } = useDebugRecording();
|
||||
const startOpen = useRecorderStartOpen();
|
||||
const id = session?.id;
|
||||
|
||||
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
|
||||
|
||||
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
|
||||
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
|
||||
const until = session?.until;
|
||||
const minutes = me?.diagnostics?.session_minutes;
|
||||
useEffect(() => {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
const timer = window.setTimeout(() => {
|
||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
openComposer(id);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, wait);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [id, until, minutes, qc, t]);
|
||||
|
||||
return startOpen ? <StartDialog /> : null;
|
||||
}
|
||||
|
||||
function StartDialog() {
|
||||
const { t } = useTranslation();
|
||||
const me = useMe();
|
||||
const { start } = useDebugRecording();
|
||||
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
|
||||
const days = me.diagnostics.retention_days;
|
||||
const minutes = me.diagnostics.session_minutes;
|
||||
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
|
||||
return (
|
||||
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("recorder.start.level")}
|
||||
options={levels.map((l) => ({
|
||||
value: l,
|
||||
label: t(`settings.diagnostics.levels.${l}`),
|
||||
}))}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
|
||||
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
||||
<p>{t(`recorder.start.what.${level}`)}</p>
|
||||
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
||||
<p className="text-muted">
|
||||
{t("recorder.start.kept", { count: days })}{" "}
|
||||
{t("recorder.start.cap", { count: minutes })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={closeRecorderStart}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={start.isPending}
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={() => start.mutate(level)}
|
||||
>
|
||||
{t("recorder.start.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pill: how long it has been recording, and the three things to do while it does. */
|
||||
function RecorderPill() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop, shoot, markStep, screenshot } = useDebugRecording();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [stepOpen, setStepOpen] = useState(false);
|
||||
const stepButton = useRef<HTMLButtonElement>(null);
|
||||
const steps = useStepCount(session?.id ?? "");
|
||||
if (!session) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("recorder.pill.label")}
|
||||
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
||||
hidden ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
|
||||
/>
|
||||
<Elapsed since={session.started_at} />
|
||||
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
|
||||
{t(`settings.diagnostics.levels.${session.level}`)}
|
||||
</span>
|
||||
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
|
||||
<Button
|
||||
ref={stepButton}
|
||||
variant="outline-accent"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Flag className="w-4 h-4" />}
|
||||
label={t("recorder.pill.step")}
|
||||
aria-expanded={stepOpen}
|
||||
onClick={() => setStepOpen((v) => !v)}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.step")}
|
||||
{steps ? ` · ${steps}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
{canCaptureTab() && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Camera className="w-4 h-4" />}
|
||||
label={t("recorder.pill.screenshot")}
|
||||
loading={shoot.isPending}
|
||||
onClick={() => void screenshot(setHidden)}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.screenshot")}
|
||||
{session.screenshots ? ` · ${session.screenshots}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Square className="w-3.5 h-3.5 fill-current" />}
|
||||
label={t("recorder.pill.stop")}
|
||||
loading={stop.isPending}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
|
||||
</Button>
|
||||
{stepOpen && (
|
||||
<StepPrompt
|
||||
id={session.id}
|
||||
onDone={(label) => {
|
||||
if (label !== null) markStep(label);
|
||||
setStepOpen(false);
|
||||
stepButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Elapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return (
|
||||
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
|
||||
// red dot already says what it is.
|
||||
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
||||
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
|
||||
<span className="sm:hidden">{time}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */
|
||||
export function RecorderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop } = useDebugRecording();
|
||||
if (!session)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={openRecorderStart}
|
||||
>
|
||||
{t("recorder.settings.start")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
|
||||
<SettingsElapsed since={session.started_at} />
|
||||
</span>
|
||||
<Button
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
loading={stop.isPending}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
{t("recorder.settings.stop")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsElapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
|
||||
}
|
||||
|
||||
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
|
||||
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const n = useStepCount(id) + 1;
|
||||
const [label, setLabel] = useState("");
|
||||
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
|
||||
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last.
|
||||
useLayer(true, () => onDone(null));
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onDone(label);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
<input
|
||||
id="debug-step-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={200}
|
||||
className={`${inputCls} w-full`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{t("recorder.pill.stepHint")}</span>
|
||||
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
|
||||
{t("recorder.pill.mark")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user