From 46fce4a76790c91f8c24f3112fffdf03056f57c1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:08:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(diagnostics):=20the=20debug=20recorder=20?= =?UTF-8?q?=E2=80=94=20start,=20the=20pill,=20steps,=20screenshots,=20stop?= =?UTF-8?q?=20(E16=20S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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' --- backend/app/debugsession.py | 27 +- backend/app/diagnostics.py | 4 + backend/tests/test_debug_sessions.py | 22 +- backend/tests/test_diagnostics.py | 2 + frontend/src/App.tsx | 17 +- .../components/AppCommandPalette.dom.test.tsx | 21 +- frontend/src/components/AppCommandPalette.tsx | 34 +- .../src/components/DebugRecorder.dom.test.tsx | 203 ++++++++++++ frontend/src/components/DebugRecorder.tsx | 296 ++++++++++++++++++ .../DiagnosticsSettings.dom.test.tsx | 13 +- .../src/components/DiagnosticsSettings.tsx | 7 + frontend/src/components/useDebugRecording.ts | 99 ++++++ frontend/src/i18n/locales/en/recorder.json | 47 +++ frontend/src/i18n/locales/hu/recorder.json | 47 +++ frontend/src/lib/api.ts | 3 +- frontend/src/lib/api/account.ts | 4 +- frontend/src/lib/api/core.test.ts | 31 ++ frontend/src/lib/api/core.ts | 34 +- frontend/src/lib/api/diagnostics.ts | 67 +++- frontend/src/lib/api/facade.test.ts | 1 + frontend/src/lib/captureTab.ts | 62 ++++ frontend/src/lib/debugRecorder.ts | 49 +++ frontend/src/lib/diagnostics.dom.test.ts | 77 +++-- .../src/lib/diagnostics.session.dom.test.ts | 141 +++++++++ frontend/src/lib/diagnostics.ts | 111 +++++-- frontend/src/lib/log.ts | 17 + 26 files changed, 1347 insertions(+), 89 deletions(-) create mode 100644 frontend/src/components/DebugRecorder.dom.test.tsx create mode 100644 frontend/src/components/DebugRecorder.tsx create mode 100644 frontend/src/components/useDebugRecording.ts create mode 100644 frontend/src/i18n/locales/en/recorder.json create mode 100644 frontend/src/i18n/locales/hu/recorder.json create mode 100644 frontend/src/lib/captureTab.ts create mode 100644 frontend/src/lib/debugRecorder.ts create mode 100644 frontend/src/lib/diagnostics.session.dom.test.ts diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py index 5aaa847e..98a18025 100644 --- a/backend/app/debugsession.py +++ b/backend/app/debugsession.py @@ -22,14 +22,14 @@ from datetime import datetime, timedelta, timezone from enum import StrEnum from typing import Literal, get_args -from sqlalchemy import delete, func, select, update +from sqlalchemy import and_, delete, func, not_, select, update from sqlalchemy.orm import Session, defer from app import diagnostics from app.models import DebugScreenshot, DebugSession, LogEvent, User HEADER = "x-debug-session" -MAX_MINUTES = 60 +MAX_MINUTES = diagnostics.SESSION_MINUTES SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 SCREENSHOTS_MAX = 8 # Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the @@ -216,13 +216,28 @@ def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]: # --- the evidence -------------------------------------------------------------------------- +# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence +# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The +# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`). +INGEST_PATH = "/api/diagnostics/events" + + +def _evidence(session: DebugSession): + upload_line = and_( + LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH + ) + # `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and + # `NOT NULL` would drop exactly the rows that are evidence. + return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True))) + + def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]: """The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and how many rows it has in all, so a cut bundle can say so.""" rows = list( db.scalars( select(LogEvent) - .where(LogEvent.debug_session_id == session.id) + .where(*_evidence(session)) .order_by(LogEvent.ts, LogEvent.id) .limit(BUNDLE_MAX) ) @@ -230,11 +245,7 @@ def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]: total = len(rows) if total == BUNDLE_MAX: total = ( - db.scalar( - select(func.count()) - .select_from(LogEvent) - .where(LogEvent.debug_session_id == session.id) - ) + db.scalar(select(func.count()).select_from(LogEvent).where(*_evidence(session))) or total ) return rows, total diff --git a/backend/app/diagnostics.py b/backend/app/diagnostics.py index 89a78e9e..38e5adb4 100644 --- a/backend/app/diagnostics.py +++ b/backend/app/diagnostics.py @@ -27,6 +27,9 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"] LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel) BASE: DiagnosticsLevel = "INFO" ELEVATION_HOURS = 24 +# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other +# consent window, because the start dialog's consent text states it and reads it from `state`. +SESSION_MINUTES = 60 def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel: @@ -47,6 +50,7 @@ def state(db: Session, user: User) -> dict: "level": level, "until": user.diagnostics_until.isoformat() if level != BASE else None, "retention_days": sysconfig.get_int(db, "log_retention_days"), + "session_minutes": SESSION_MINUTES, } diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py index 50a9bc96..f279d489 100644 --- a/backend/tests/test_debug_sessions.py +++ b/backend/tests/test_debug_sessions.py @@ -335,15 +335,16 @@ def test_the_evidence_is_the_sessions_rows_in_time_order(db, make_user, as_user) sid = _start(client)["id"] now = datetime.now(timezone.utc) - def row(ts, source, session): + def row(ts, source, session, event="x.y", attrs=None): return LogEvent( ts=ts, level="INFO", sev=9, source=source, logger=source, - event="x.y", + event=event, msg=source, + attrs=attrs, user_id=user.id, debug_session_id=session, ) @@ -353,15 +354,20 @@ def test_the_evidence_is_the_sessions_rows_in_time_order(db, make_user, as_user) row(now + timedelta(seconds=2), "api", sid), row(now + timedelta(seconds=1), "web", sid), row(now, "web", None), + # The recording's own log upload is plumbing, not evidence… + row(now, "api", sid, "http.request", {"path": debugsession.INGEST_PATH}), + # …but a server line with no event code and no attrs is evidence all the same. + row(now + timedelta(seconds=3), "api", sid, None), ] ) db.commit() body = client.get(f"/api/debug-sessions/{sid}/events").json() - assert [(i["source"], i["debug_session_id"]) for i in body["items"]] == [ - ("web", sid), - ("api", sid), + assert [(i["source"], i["event"]) for i in body["items"]] == [ + ("web", "x.y"), + ("api", "x.y"), + ("api", None), ] - assert body["total"] == 2 + assert body["total"] == 3 # --- screenshots ---------------------------------------------------------------------------- @@ -436,3 +442,7 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording): assert r.json() == {"ok": True, "deleted": shot} r = client.get(f"/api/debug-sessions/{sid}/screenshots/{shot}") assert r.status_code == 404 and r.json()["detail"]["reason"] == "screenshot" + + +def test_the_upload_path_left_out_of_the_evidence_is_the_real_one(): + assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes} diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py index d2915a23..a64b4712 100644 --- a/backend/tests/test_diagnostics.py +++ b/backend/tests/test_diagnostics.py @@ -68,6 +68,7 @@ def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db, "level": "INFO", "until": None, "retention_days": 7, + "session_minutes": 60, } @@ -82,6 +83,7 @@ def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user): "level": "INFO", "until": None, "retention_days": 7, + "session_minutes": 60, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49853ba7..33fc48b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ import BackToTop from "./components/ui/BackToTop"; import PageShell from "./components/ui/PageShell"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; +import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; import KeyboardHelp from "./components/ui/KeyboardHelp"; import AppCommandPalette from "./components/AppCommandPalette"; @@ -147,12 +148,18 @@ export default function App() { ); // The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is - // kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or - // the server saying it is off) refetches `me`, so Settings shows the base level again. + // kept in more detail and streamed to the server until the level lapses. A debug session that is + // recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the + // server saying it is off) refetches `me`, so Settings and the recorder show what is in force. const diagnostics = meQuery.data?.diagnostics; + const debugSession = meQuery.data?.debug_session; useEffect(() => { - applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() })); - }, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps + applyDiagnostics( + diagnostics, + debugSession, + () => void queryClient.invalidateQueries({ queryKey: qk.me() }), + ); + }, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch the code of every module this account can open — and the channel page — once the first page // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. @@ -411,6 +418,8 @@ export default function App() { {meQuery.data && !meQuery.data.is_demo && } + {/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */} + {meQuery.data && !meQuery.data.is_demo && } {aboutOpen && ( ({ +vi.mock("react-i18next", async (importOriginal) => ({ + // The rest of the module too: the recorder's actions reach `i18n/index`, which needs it. + ...(await importOriginal()), useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), })); @@ -140,4 +142,21 @@ describe("AppCommandPalette", () => { then?.(); expect(requestSurprise).toHaveBeenCalledTimes(1); }); + + it("offers the debug recorder by its state, and not to the demo account", () => { + const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:")); + mount(); + expect(recorder()).toEqual(["action:recorder:start"]); + + cleanup(); + const until = new Date(Date.now() + 60_000).toISOString(); + me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } }); + mount(); + expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]); + + cleanup(); + me = user({ is_demo: true }); + mount(); + expect(recorder()).toEqual([]); + }); }); diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx index 2951848e..5283d117 100644 --- a/frontend/src/components/AppCommandPalette.tsx +++ b/frontend/src/components/AppCommandPalette.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react"; +import { Bug, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, Tv } from "lucide-react"; import CommandPalette, { type CommandGroup } from "./ui/CommandPalette"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; @@ -10,6 +10,8 @@ import { moduleDef, moduleOrder } from "../lib/modules"; import { SCHEMES, type Scheme } from "../lib/theme"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { requestSurprise } from "../lib/homeIntent"; +import { openRecorderStart } from "../lib/debugRecorder"; +import { useDebugRecording } from "./useDebugRecording"; import { useNavigationActions } from "./navigationContext"; import { usePlaylistsActions } from "./playlistsContext"; import { usePrefs } from "./prefsContext"; @@ -34,6 +36,7 @@ export default function AppCommandPalette() { const { setSelectedId } = usePlaylistsActions(); const { theme, setTheme } = usePrefs(); const savedViews = useSavedViews(); + const recording = useDebugRecording(); // Only fetch the lists while the palette is actually open — it is a rarely-opened surface and // these are otherwise the feed's and the playlist rail's own queries, shared through the cache. @@ -147,6 +150,34 @@ export default function AppCommandPalette() { icon: , run: toggleKeyboardHelp, }, + // The debug recorder (E16) — not for the shared demo account, which cannot record. While + // it runs, Stop and a step marker take Start's place: the palette is reachable inside a + // player or a dialog where the pill's buttons are a long Tab away. + ...(me?.is_demo + ? [] + : recording.session + ? [ + { + id: "action:recorder:step", + label: t("recorder.palette.step"), + icon: , + run: () => recording.markStep(""), + }, + { + id: "action:recorder:stop", + label: t("recorder.palette.stop"), + icon: , + run: () => recording.stop.mutate(recording.session!.id), + }, + ] + : [ + { + id: "action:recorder:start", + label: t("recorder.palette.start"), + icon: , + run: openRecorderStart, + }, + ]), ...SCHEMES.map((s) => ({ id: `action:theme:${s.id}`, label: t("palette.theme", { name: s.name }), @@ -166,6 +197,7 @@ export default function AppCommandPalette() { savedViews, theme, setTheme, + recording, setPage, openChannel, setSelectedId, diff --git a/frontend/src/components/DebugRecorder.dom.test.tsx b/frontend/src/components/DebugRecorder.dom.test.tsx new file mode 100644 index 00000000..0a6eb327 --- /dev/null +++ b/frontend/src/components/DebugRecorder.dom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +// +// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their +// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the +// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the +// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me` +// lives in a real query cache, as in the app. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { DebugSession, Me } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key, + i18n: { language: "en" }, + }), + }; +}); + +const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => { + const order: string[] = []; + const session = (over: Partial = {}): DebugSession => ({ + id: "0123456789abcdef", + level: "DEBUG", + started_at: new Date().toISOString(), + until: new Date(Date.now() + 3_600_000).toISOString(), + ended_at: null, + ended_reason: null, + issue_url: null, + screenshots: 0, + ...over, + }); + const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") => + Promise.resolve({ ok: true as const, session: session({ level }) }), + ); + const stopDebugSession = vi.fn((id: string) => { + order.push("stop"); + return Promise.resolve({ + ok: true as const, + session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }), + }); + }); + return { order, session, startDebugSession, stopDebugSession }; +}); +vi.mock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, startDebugSession, stopDebugSession } }; +}); +vi.mock("../lib/diagnostics", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + flushNow: () => { + order.push("flush"); + return Promise.resolve(); + }, + }; +}); + +import DebugRecorder from "./DebugRecorder"; +import Modal from "./ui/Modal"; +import Toaster from "./ui/Toaster"; +import { closeComposer, openRecorderStart, useComposerSession } from "../lib/debugRecorder"; +import { events } from "../lib/log"; + +beforeEach(() => { + order.length = 0; + sessionStorage.clear(); +}); +afterEach(() => { + cleanup(); + closeComposer(); + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +let composing: string | null = null; +function ComposerProbe() { + composing = useComposerSession(); + return null; +} + +function mount(me: Partial, extra: React.ReactNode = null) { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + qc.setQueryData(qk.me(), { + id: 1, + is_demo: false, + preferences: {}, + diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, + debug_session: null, + ...me, + }); + render( + + + + + {extra} + , + ); + return qc; +} + +const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" }); + +describe("the recorder pill", () => { + it("shows while a session records, under the toasts and outside their live region", () => { + mount({ debug_session: session() }); + const p = pill()!; + expect(p).not.toBeNull(); + expect(p.closest("[data-toast-region]")).not.toBeNull(); + expect(p.closest("[aria-live]")).toBeNull(); + }); + + it("is not there for a session that ended or ran past its cap", () => { + mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) }); + expect(pill()).toBeNull(); + cleanup(); + mount({ debug_session: session({ ended_at: new Date().toISOString() }) }); + expect(pill()).toBeNull(); + }); + + it("Stop sends what is waiting first, then stops, then opens the composer", async () => { + const qc = mount({ debug_session: session() }); + fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" })); + await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef")); + expect(order).toEqual(["flush", "stop"]); + await waitFor(() => expect(pill()).toBeNull()); + expect(qc.getQueryData(qk.me())?.debug_session).toBeNull(); + expect(composing).toBe("0123456789abcdef"); + }); + + it("a step is one tagged event, numbered, and the focus comes back to Step", () => { + mount({ debug_session: session() }); + const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" }); + fireEvent.click(step); + const input = screen.getByLabelText("recorder.pill.stepPrompt:1"); + expect(document.activeElement).toBe(input); + fireEvent.change(input, { target: { value: "opened the player" } }); + fireEvent.keyDown(input, { key: "Enter" }); + const marked = events().filter((e) => e.event === "debug.step"); + expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" }); + expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull(); + expect(document.activeElement).toBe(step); + expect(step.textContent).toContain("· 1"); + }); + + it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => { + const onClose = vi.fn(); + mount( + { debug_session: session() }, + +

inside

+
, + ); + // The pill moved into the dialog's own toast host. + expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); + const marked = () => events().filter((e) => e.event === "debug.step").length; + const before = marked(); + fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" })); + fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" }); + expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + expect(marked()).toBe(before); + }); + + it("offers a screenshot only where the browser can capture a tab", () => { + mount({ debug_session: session() }); + // jsdom has no Screen Capture API — like iOS Safari. + expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull(); + }); + + it("at its cap it says so and opens the composer", () => { + vi.useFakeTimers(); + mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) }); + act(() => void vi.advanceTimersByTime(5_000)); + expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull(); + expect(composing).toBe("0123456789abcdef"); + }); +}); + +describe("the start dialog", () => { + it("states the retention and the cap, and starts at the level chosen", async () => { + const qc = mount({}); + act(() => openRecorderStart()); + expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull(); + expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" })); + expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ })); + await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE")); + await waitFor(() => expect(pill()).not.toBeNull()); + expect(qc.getQueryData(qk.me())?.debug_session?.level).toBe("TRACE"); + expect(screen.queryByText("recorder.start.title")).toBeNull(); + }); +}); diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx new file mode 100644 index 00000000..a72f2b8d --- /dev/null +++ b/frontend/src/components/DebugRecorder.tsx @@ -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 ? : null; +} + +function StartDialog() { + const { t } = useTranslation(); + const me = useMe(); + const { start } = useDebugRecording(); + const [level, setLevel] = useState("DEBUG"); + const days = me.diagnostics.retention_days; + const minutes = me.diagnostics.session_minutes; + const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"]; + return ( + +
+

{t("recorder.start.intro")}

+
+ {t("recorder.start.level")} + ({ + value: l, + label: t(`settings.diagnostics.levels.${l}`), + }))} + value={level} + onChange={setLevel} + /> +
+
+
{t("recorder.start.whatTitle")}
+

{t(`recorder.start.what.${level}`)}

+

{t("settings.diagnostics.consent.never")}

+

+ {t("recorder.start.kept", { count: days })}{" "} + {t("recorder.start.cap", { count: minutes })} +

+
+
+ + +
+
+
+ ); +} + +/** 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(null); + const steps = useStepCount(session?.id ?? ""); + if (!session) return null; + return ( + + ); +} + +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. + + {t("recorder.pill.recording", { time })} + {time} + + ); +} + +/** 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 ( +
+

{t("recorder.settings.hint")}

+ +
+ ); + return ( +
+ + + + + +
+ ); +} + +function SettingsElapsed({ since }: { since: string }) { + const { t } = useTranslation(); + const time = useElapsed(since); + return {t("recorder.settings.recording", { time })}; +} + +/** "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) => { + if (e.key === "Enter") { + e.preventDefault(); + onDone(label); + } + }; + return ( +
+ + setLabel(e.target.value)} + onKeyDown={onKeyDown} + maxLength={200} + className={`${inputCls} w-full`} + /> +
+ {t("recorder.pill.stepHint")} + +
+
+ ); +} diff --git a/frontend/src/components/DiagnosticsSettings.dom.test.tsx b/frontend/src/components/DiagnosticsSettings.dom.test.tsx index 882c6c65..ff0dd1f5 100644 --- a/frontend/src/components/DiagnosticsSettings.dom.test.tsx +++ b/frontend/src/components/DiagnosticsSettings.dom.test.tsx @@ -37,6 +37,7 @@ const setLevel = vi.fn( level, until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(), retention_days: 7, + session_minutes: 60, }, }), ); @@ -61,7 +62,7 @@ function mount(me: Partial) { id: 1, is_demo: false, preferences: {}, - diagnostics: { level: "INFO", until: null, retention_days: 7 }, + diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, ...me, }); render( @@ -131,7 +132,7 @@ describe("DiagnosticsSettings", () => { it("going back to the base level needs no consent", async () => { const until = new Date(Date.now() + 3_600_000).toISOString(); - mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); + mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } }); fireEvent.click(levelButton("settings.diagnostics.levels.INFO")); expect(screen.queryByTestId("diagnostics-consent")).toBeNull(); await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO")); @@ -169,7 +170,7 @@ describe("DiagnosticsSettings", () => { try { // Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering. const until = new Date(Date.now() + 3_600_000).toISOString(); - mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); + mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } }); fireEvent.click(levelButton("settings.diagnostics.levels.TRACE")); const elsewhere = document.createElement("button"); document.body.append(elsewhere); @@ -194,7 +195,7 @@ describe("DiagnosticsSettings", () => { it("the ticking status line is not a live region", async () => { const until = new Date(Date.now() + 3_600_000).toISOString(); - mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); + mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } }); const status = screen.getByTestId("diagnostics-status"); expect(status.closest('[role="status"], [aria-live]')).toBeNull(); }); @@ -215,7 +216,7 @@ describe("DiagnosticsSettings", () => { id: 1, is_demo: false, preferences: {}, - diagnostics: { level: "INFO", until: null, retention_days: 7 }, + diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, }); await stale; expect(qc.getQueryData(qk.me())?.diagnostics.level).toBe("TRACE"); @@ -224,7 +225,7 @@ describe("DiagnosticsSettings", () => { it("a lapsed level shows as the base level", () => { const until = new Date(Date.now() - 60_000).toISOString(); - mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } }); + mount({ diagnostics: { level: "TRACE", until, retention_days: 7, session_minutes: 60 } }); expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe( "true", ); diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx index 420459ed..c49fe922 100644 --- a/frontend/src/components/DiagnosticsSettings.tsx +++ b/frontend/src/components/DiagnosticsSettings.tsx @@ -10,6 +10,7 @@ import InlineNotice from "./ui/InlineNotice"; import SegmentedToggle from "./ui/SegmentedToggle"; import { Section, SettingRow } from "./ui/form"; import MyLogViewer from "./MyLogViewer"; +import { RecorderSettings } from "./DebugRecorder"; const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"]; @@ -181,6 +182,12 @@ export default function DiagnosticsSettings() { )} + {!me.is_demo && ( +
+ +
+ )} +
diff --git a/frontend/src/components/useDebugRecording.ts b/frontend/src/components/useDebugRecording.ts new file mode 100644 index 00000000..5f58d023 --- /dev/null +++ b/frontend/src/components/useDebugRecording.ts @@ -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 }; +} diff --git a/frontend/src/i18n/locales/en/recorder.json b/frontend/src/i18n/locales/en/recorder.json new file mode 100644 index 00000000..77fd0bd4 --- /dev/null +++ b/frontend/src/i18n/locales/en/recorder.json @@ -0,0 +1,47 @@ +{ + "start": { + "title": "Debug session", + "intro": "Start it, go through whatever breaks, then stop it. You get a draft bug report with the log attached, and you can review everything before it is sent.", + "level": "Detail", + "whatTitle": "What it records", + "what": { + "DEBUG": "Page changes, requests and errors, which button, filter and player control you use, and the filters' values.", + "TRACE": "Page changes, requests and errors, which button, filter and player control you use, the filters' values, and what you type into search." + }, + "kept_one": "The server keeps it for {{count}} day.", + "kept_other": "The server keeps it for {{count}} days.", + "cap_one": "It runs for {{count}} minute at most, then stops on its own. When it stops, your previous log level is back.", + "cap_other": "It runs for {{count}} minutes at most, then stops on its own. When it stops, your previous log level is back.", + "confirm": "Start recording", + "failed": "Couldn't start recording" + }, + "pill": { + "label": "Debug recording", + "recording": "Recording · {{time}}", + "step": "Step", + "screenshot": "Screenshot", + "stop": "Stop", + "stepPrompt": "Step {{n}} — what did you just do? (optional)", + "stepHint": "Enter: mark · Esc: cancel", + "mark": "Mark", + "stepMarked": "Step {{n}} marked", + "shotSaved": "Screenshot saved", + "shotFailed": "Couldn't save the screenshot", + "stopFailed": "Couldn't stop recording", + "stopped": "Recording stopped", + "expired_one": "The recording reached its {{count}}-minute limit and stopped.", + "expired_other": "The recording reached its {{count}}-minute limit and stopped." + }, + "settings": { + "title": "Debug session", + "hint": "When something breaks: start recording, go through it again, then stop. A draft bug report is made from the log.", + "start": "Start recording", + "recording": "Recording · {{time}}", + "stop": "Stop" + }, + "palette": { + "start": "Start a debug recording", + "step": "Mark a step in the recording", + "stop": "Stop the debug recording" + } +} diff --git a/frontend/src/i18n/locales/hu/recorder.json b/frontend/src/i18n/locales/hu/recorder.json new file mode 100644 index 00000000..eea58e73 --- /dev/null +++ b/frontend/src/i18n/locales/hu/recorder.json @@ -0,0 +1,47 @@ +{ + "start": { + "title": "Hibakereső munkamenet", + "intro": "Indítsd el, csináld végig, ami elromlik, aztán állítsd le. A végén hibajegy-vázlatot kapsz a naplóval együtt, és elküldés előtt mindent átnézhetsz.", + "level": "Részletesség", + "whatTitle": "Mit rögzít", + "what": { + "DEBUG": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a szűrők értékeit.", + "TRACE": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, a szűrők értékeit, és a keresőbe beírt szöveget is." + }, + "kept_one": "A szerver {{count}} napig őrzi.", + "kept_other": "A szerver {{count}} napig őrzi.", + "cap_one": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.", + "cap_other": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.", + "confirm": "Rögzítés indítása", + "failed": "Nem sikerült elindítani a rögzítést" + }, + "pill": { + "label": "Hibakereső rögzítés", + "recording": "Rögzítés · {{time}}", + "step": "Lépés", + "screenshot": "Képernyőkép", + "stop": "Leállítás", + "stepPrompt": "{{n}}. lépés — mit csináltál most? (nem kötelező)", + "stepHint": "Enter: megjelöl · Esc: mégse", + "mark": "Megjelöl", + "stepMarked": "{{n}}. lépés megjelölve", + "shotSaved": "Képernyőkép elmentve", + "shotFailed": "Nem sikerült elmenteni a képernyőképet", + "stopFailed": "Nem sikerült leállítani a rögzítést", + "stopped": "Rögzítés leállítva", + "expired_one": "A rögzítés elérte a {{count}} perces határt, és leállt.", + "expired_other": "A rögzítés elérte a {{count}} perces határt, és leállt." + }, + "settings": { + "title": "Hibakereső munkamenet", + "hint": "Ha valami elromlik: indítsd el a rögzítést, csináld végig újra, aztán állítsd le. A naplóból hibajegy-vázlat készül.", + "start": "Rögzítés indítása", + "recording": "Rögzítés folyamatban · {{time}}", + "stop": "Leállítás" + }, + "palette": { + "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" + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4f45b1df..5dd9fc72 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,7 +68,7 @@ import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; import { downloadsApi } from "./api/downloads"; export type * from "./api/downloads"; -import { diagnosticsApi } from "./api/diagnostics"; +import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics"; export type * from "./api/diagnostics"; export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics"; @@ -90,4 +90,5 @@ export const api = { ...messagingApi, ...downloadsApi, ...diagnosticsApi, + ...debugSessionsApi, }; diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 713711ef..d672145b 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -1,7 +1,7 @@ // The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out, // app version, and preference save. Only touches core's req. import { req } from "./core"; -import type { DiagnosticsState } from "./diagnostics"; +import type { DebugSession, DiagnosticsState } from "./diagnostics"; export interface Me { id: number; @@ -33,6 +33,8 @@ export interface Me { preferences: Record; /** The consented diagnostics level in force and when it lapses (E22 S5). */ diagnostics: DiagnosticsState; + /** The debug session recording right now, if any (E16) — a reload resumes it. */ + debug_session: DebugSession | null; } /** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user` diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index 140ecb9a..83b922a0 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -22,7 +22,9 @@ vi.mock("../activeAccount", () => ({ pinActiveAccount: () => {}, })); +import { setDebugSession } from "../log"; import { + beacon, req, uploadReq, setUnauthorizedHandler, @@ -310,3 +312,32 @@ describe("req — what it has already put in front of the user", () => { expect(remove).not.toHaveBeenCalled(); }); }); + +// E16: while a debug session records, every request path names it — the server tags ITS lines of +// that request with it. A path that forgot the header would leave holes in the evidence exactly +// where the upload (XHR) or the unload beacon happened. +describe("the debug session a tab records into", () => { + const sentHeaders = () => { + const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit; + return new Headers(init.headers as HeadersInit); + }; + + it("rides every request path while it records, and none once it stops", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response("{}", { status: 200 })), + ); + setDebugSession("0123456789abcdef"); + try { + await req("/api/x"); + expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef"); + beacon("/api/y", {}); + expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef"); + void uploadReq("/u", form()); + expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef"); + } finally { + setDebugSession(null); + } + await req("/api/x"); + expect(sentHeaders().has("X-Debug-Session")).toBe(false); + }); +}); diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 9d1dfc2a..3f18f00c 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -8,7 +8,7 @@ import { formatBytes } from "../format"; import { reportError } from "../errorDialog"; import { activeAccountId, pinActiveAccount } from "../activeAccount"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; -import { newRequestId } from "../log"; +import { currentDebugSession, newRequestId } from "../log"; /** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB"). * Lives here so the request builders can stamp it themselves: a signal that has to be passed in by @@ -253,24 +253,35 @@ export const setupHeaders = (token: string) => ({ // sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. // The storage key + reader live in storage.ts (so its per-account helpers can use them too). const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; +// The debug session this tab is recording (E16): the server tags its own lines of the request with +// it, and logs them at the session's level. Ignored by the server unless it is the caller's own. +const DEBUG_SESSION_HEADER = "X-Debug-Session"; export const getActiveAccount = activeAccountId; +/** Who this request is from — the tab's account and the debug session it records into — as the + * headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path + * cannot quietly go out without them. */ +export function identityHeaders(): Record { + const active = getActiveAccount(); + const session = currentDebugSession(); + return { + ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + ...(session ? { [DEBUG_SESSION_HEADER]: session } : {}), + }; +} + // Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position // beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup // doesn't run on a full reload, so without keepalive the last position (esp. right after a seek) // would be lost. export function beacon(url: string, body: Record): void { - const active = getActiveAccount(); try { void send(url, { method: "POST", keepalive: true, credentials: "include", - headers: { - "Content-Type": "application/json", - ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), - }, + headers: { "Content-Type": "application/json", ...identityHeaders() }, body: JSON.stringify(body), }).catch(() => {}); } catch { @@ -292,16 +303,15 @@ export async function req( for (;;) { let r: Response; try { - const active = getActiveAccount(); r = await send( url, { credentials: "include", headers: { "Content-Type": "application/json", - // Per-tab identity override (only setup calls pass their own headers, and those are - // pre-auth, so this is safe to put in the defaults that `...opts` may replace). - ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + // Per-tab identity override (only setup calls and raw uploads pass their own headers: + // setup is pre-auth, and an upload spreads `identityHeaders()` into its own). + ...identityHeaders(), }, ...opts, }, @@ -372,8 +382,8 @@ export function uploadReq( const requestId = newRequestId(); const started = performance.now(); xhr.setRequestHeader(REQUEST_ID_HEADER, requestId); - const active = getActiveAccount(); - if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active)); + for (const [name, value] of Object.entries(identityHeaders())) + xhr.setRequestHeader(name, value); if (cfg.onProgress) { xhr.upload.onprogress = (e) => { if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100)); diff --git a/frontend/src/lib/api/diagnostics.ts b/frontend/src/lib/api/diagnostics.ts index d33c2fe2..6468f985 100644 --- a/frontend/src/lib/api/diagnostics.ts +++ b/frontend/src/lib/api/diagnostics.ts @@ -1,7 +1,8 @@ // The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and // beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`. import type { LogEvent } from "../log"; -import { beacon, req } from "./core"; +import type { ServerLogRow } from "./admin"; +import { beacon, getActiveAccount, identityHeaders, req } from "./core"; /** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */ export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE"; @@ -12,6 +13,8 @@ export interface DiagnosticsState { until: string | null; /** How many days this instance keeps stored log lines — the consent text states it. */ retention_days: number; + /** How long a debug session records at most — the start dialog's consent text states it. */ + session_minutes: number; } /** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or @@ -33,3 +36,65 @@ export const diagnosticsApi = { /** The last batch of a closing tab (keepalive, fire-and-forget). */ sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }), }; + +// --- debug sessions (E16) ---------------------------------------------------------------------- + +/** The two levels a recording can run at — it always records more than the base. */ +export type DebugSessionLevel = "DEBUG" | "TRACE"; + +/** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is + * over whether or not it was stopped. */ +export interface DebugSession { + id: string; + level: DebugSessionLevel; + started_at: string; + until: string; + ended_at: string | null; + ended_reason: "stopped" | "expired" | null; + /** The issue it was sent as, once sent. */ + issue_url: string | null; + screenshots: number; +} + +export interface DebugScreenshot { + id: number; + mime: string; + size: number; + created_at: string; +} + +const SESSIONS = "/api/debug-sessions"; + +export const debugSessionsApi = { + startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> => + req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }), + stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> => + req(`${SESSIONS}/${id}/stop`, { method: "POST" }), + debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS), + debugSession: (id: string): Promise => req(`${SESSIONS}/${id}`), + /** The rows an issue from this session would carry, browser and server, in time order. */ + debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> => + req(`${SESSIONS}/${id}/events`), + debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> => + req(`${SESSIONS}/${id}/screenshots`), + /** One image as the raw body — the server checks what it really is, whatever `type` says. */ + addDebugScreenshot: ( + id: string, + image: Blob, + ): Promise<{ ok: true; screenshot: DebugScreenshot }> => + req(`${SESSIONS}/${id}/screenshots`, { + method: "POST", + headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() }, + body: image, + }), + removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> => + req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }), + /** Where an `` loads a screenshot from. An `` cannot send the account header, so the + * tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a + * per-tab account's screenshot resolves to the session default and 404s. */ + debugScreenshotUrl: (id: string, shotId: number): string => { + const a = getActiveAccount(); + const url = `${SESSIONS}/${id}/screenshots/${shotId}`; + return a != null ? `${url}?account=${a}` : url; + }, +}; diff --git a/frontend/src/lib/api/facade.test.ts b/frontend/src/lib/api/facade.test.ts index 6c5efa96..936264f7 100644 --- a/frontend/src/lib/api/facade.test.ts +++ b/frontend/src/lib/api/facade.test.ts @@ -33,6 +33,7 @@ const SLICE_MODULES = [ ["messagingApi", () => import("./messaging")], ["downloadsApi", () => import("./downloads")], ["diagnosticsApi", () => import("./diagnostics")], + ["debugSessionsApi", () => import("./diagnostics")], ] as const; let api: Slice; diff --git a/frontend/src/lib/captureTab.ts b/frontend/src/lib/captureTab.ts new file mode 100644 index 00000000..bb93866f --- /dev/null +++ b/frontend/src/lib/captureTab.ts @@ -0,0 +1,62 @@ +// One screenshot of this tab, for a debug session (E16 D5). +// +// Through the Screen Capture API with the current tab preferred: the browser asks every time (there +// is no silent capture on the web, by design), the user can pick something else or cancel, and one +// frame is taken before the track is stopped at once — nothing keeps recording. +// +// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the +// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same +// name Chrome gives a cancelled picker. +// +// The frame is read through a