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,99 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { captureTab } from "../lib/captureTab";
|
||||
import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder";
|
||||
import { flushNow, liveSession } from "../lib/diagnostics";
|
||||
import { log } from "../lib/log";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { updateMeCache, useMe } from "../lib/useMe";
|
||||
|
||||
/** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings →
|
||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||
export function useDebugRecording() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const session = liveSession(me?.debug_session);
|
||||
const setSession = (next: DebugSession | null) =>
|
||||
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
|
||||
meta: { failTitle: "recorder.start.failed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(r.session);
|
||||
closeRecorderStart();
|
||||
},
|
||||
});
|
||||
|
||||
const stop = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
log.info("debug.stop", "recording stopped by the user");
|
||||
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
|
||||
// while the session still tags them: once it is closed the server no longer does.
|
||||
await flushNow();
|
||||
return api.stopDebugSession(id);
|
||||
},
|
||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(null);
|
||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
||||
openComposer(r.session.id);
|
||||
},
|
||||
});
|
||||
|
||||
const shoot = useMutation({
|
||||
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
|
||||
api.addDebugScreenshot(id, image),
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: (r) => {
|
||||
updateMeCache(qc, (prev) =>
|
||||
prev.debug_session
|
||||
? {
|
||||
...prev,
|
||||
debug_session: {
|
||||
...prev.debug_session,
|
||||
screenshots: prev.debug_session.screenshots + 1,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
||||
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
||||
notify({ level: "success", message: t("recorder.pill.shotSaved") });
|
||||
},
|
||||
});
|
||||
|
||||
/** Mark the next step; `label` is what the user says they just did (may be empty). */
|
||||
const markStep = (label: string) => {
|
||||
if (!session) return;
|
||||
const n = nextStep(session.id);
|
||||
const text = label.trim();
|
||||
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
||||
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
|
||||
};
|
||||
|
||||
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
||||
* frame. Nothing happens when the user cancels the browser's picker. */
|
||||
const screenshot = async (hide: (hidden: boolean) => void) => {
|
||||
if (!session) return;
|
||||
hide(true);
|
||||
let image: Blob | null = null;
|
||||
try {
|
||||
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
} finally {
|
||||
hide(false);
|
||||
}
|
||||
if (image) shoot.mutate({ id: session.id, image });
|
||||
};
|
||||
|
||||
return { session, start, stop, shoot, markStep, screenshot };
|
||||
}
|
||||
Reference in New Issue
Block a user