From 44a792c34fff41c77b1f98866f81bdbe9d2ffa18 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 21:38:53 +0200 Subject: [PATCH 01/14] test(i18n): a 404 target the scan cannot read fails the backend-id contract what=SOME_CONSTANT reached no locale check at all; only _common.py's own pass-through may name the target by variable. --- frontend/src/i18n/backendIds.contract.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index 75ce191a..a012ac14 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -88,16 +88,26 @@ const DETAIL = "downloads.errors.fail.catalog."; // What a structured 404 says is gone → `errors.notFound.`: every `not_found("x")` and every // `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources. -const routeSources = (dir = "routes"): string[] => +const routeFiles = (dir = "routes"): string[] => readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), { withFileTypes: true, }).flatMap((e) => e.isDirectory() - ? routeSources(`${dir}/${e.name}`) + ? routeFiles(`${dir}/${e.name}`) : e.name.endsWith(".py") - ? [backend(`${dir}/${e.name}`)] + ? [`${dir}/${e.name}`] : [], ); +const routeSources = (): string[] => routeFiles().map(backend); +// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid +// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable. +const opaqueWhats = routeFiles() + .filter((f) => !f.endsWith("/_common.py")) + .flatMap((f) => + [...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map( + (m) => `${f}: ${m[0]}`, + ), + ); const goneWhats = [ ...new Set( routeSources().flatMap((src) => @@ -158,6 +168,7 @@ describe("backend ids have their names in both languages", () => { expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); + expect(opaqueWhats).toEqual([]); expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]); expect(refusedReasons).toEqual( expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]), From a7d745627947f6f98a3d63426ba17ac20437f1f5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 21:38:53 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat(diagnostics):=20debug=20sessions=20?= =?UTF-8?q?=E2=80=94=20start,=20stop,=20the=20tag=20on=20every=20line,=20s?= =?UTF-8?q?creenshots=20(E16=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session is one recording, capped at 60 minutes by the server. While it is open, a request naming the caller's own session in X-Debug-Session is logged at the session's level with every line tagged; a header naming anything else is ignored. The browser's batches count a session as consent to its level and keep the tag only on its own events. Screenshots are judged by their bytes, capped in size and count, and served back to their owner only. - debug_sessions / debug_screenshots tables, one open session per user (partial unique index), log_events.debug_session_id indexed - /api/debug-sessions: start (joins a running one), stop, list, get, events, screenshots; /api/me reports the live session - the hourly log job closes expired sessions and drops them with the logs - logsink.serialize and routes._common.capped_body shared by their readers --- CLAUDE.md | 2 +- .../alembic/versions/0089_debug_sessions.py | 81 ++++ backend/app/README.md | 4 +- backend/app/auth.py | 8 +- backend/app/debugsession.py | 261 +++++++++++ backend/app/logs.py | 23 +- backend/app/logsink.py | 22 + backend/app/main.py | 2 + backend/app/models.py | 55 ++- backend/app/routes/_common.py | 14 +- backend/app/routes/admin_logs.py | 24 +- backend/app/routes/debug_sessions.py | 183 ++++++++ backend/app/routes/diagnostics.py | 45 +- backend/app/routes/me.py | 11 +- backend/app/scheduler.py | 10 +- backend/tests/test_debug_sessions.py | 438 ++++++++++++++++++ frontend/src/i18n/locales/en/errors.json | 2 + frontend/src/i18n/locales/hu/errors.json | 2 + 18 files changed, 1133 insertions(+), 54 deletions(-) create mode 100644 backend/alembic/versions/0089_debug_sessions.py create mode 100644 backend/app/debugsession.py create mode 100644 backend/app/routes/debug_sessions.py create mode 100644 backend/tests/test_debug_sessions.py diff --git a/CLAUDE.md b/CLAUDE.md index cb86892c..f1fae3cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ how it gets built. Concretely: `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, -`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** +`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That diff --git a/backend/alembic/versions/0089_debug_sessions.py b/backend/alembic/versions/0089_debug_sessions.py new file mode 100644 index 00000000..161d117f --- /dev/null +++ b/backend/alembic/versions/0089_debug_sessions.py @@ -0,0 +1,81 @@ +"""add debug_sessions + debug_screenshots, and index log_events.debug_session_id (E16) + +Revision ID: 0089_debug_sessions +Revises: 0088_channels_feed_fallback_at +Create Date: 2026-09-19 + +A debug session is one recording: Start → reproduce → Stop (app/debugsession.py). Every log line +of that user's requests while it runs carries its id in `log_events.debug_session_id` — the column +E22 created and left NULL — and the issue it ends in is built from exactly those rows, so the column +gets its index now. Partial: nearly every row has no session, and an index of NULLs would be most +of the table for nothing. + +At most one OPEN session per user, held by a partial unique index rather than by the start handler +alone: two tabs pressing Start at once would otherwise both pass a "none open" check. + +Screenshots are rows, not files: bounded (size and count per session, app/debugsession.py), gone +with their session by cascade, and a self-host has no file volume to configure for them. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0089_debug_sessions" +down_revision: str | None = "0088_channels_feed_fallback_at" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "debug_sessions", + sa.Column("id", sa.String(16), primary_key=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("level", sa.String(8), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("until", sa.DateTime(timezone=True), nullable=False), + sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("ended_reason", sa.String(16), nullable=True), + sa.Column("issue_url", sa.String(512), nullable=True), + ) + op.create_index( + "uq_debug_sessions_open_per_user", + "debug_sessions", + ["user_id"], + unique=True, + postgresql_where=sa.text("ended_at IS NULL"), + ) + op.create_table( + "debug_screenshots", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "session_id", + sa.String(16), + sa.ForeignKey("debug_sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("mime", sa.String(16), nullable=False), + sa.Column("size", sa.Integer, nullable=False), + sa.Column("data", sa.LargeBinary, nullable=False), + ) + op.create_index( + "ix_log_events_debug_session", + "log_events", + ["debug_session_id"], + postgresql_where=sa.text("debug_session_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_log_events_debug_session", table_name="log_events") + op.drop_table("debug_screenshots") + op.drop_index("uq_debug_sessions_open_per_user", table_name="debug_sessions") + op.drop_table("debug_sessions") diff --git a/backend/app/README.md b/backend/app/README.md index ec04ecc2..af8b21b3 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -21,8 +21,8 @@ Three layers, by *who* runs the code — not by feature: 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging - configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log - level), … are single-file primitives many layers import. They depend on nothing above them (a core + configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log + level) and `debugsession` (a recording that tags its user's lines, E16), … are single-file primitives many layers import. They depend on nothing above them (a core module must never import a `routes/…` module). **Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a diff --git a/backend/app/auth.py b/backend/app/auth.py index 763954cf..cbee737c 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -4,7 +4,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from starlette.requests import HTTPConnection -from app import diagnostics +from app import debugsession from app import email as email_mod from app import logs from app import mailtext @@ -367,7 +367,11 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default # session middleware and this task's context. `request.state` is shared across middleware layers # (it lives on the ASGI scope), so the access-log line can read it back. The user's consented # diagnostics level rides along: their requests are logged — and stored — in that detail (S5). - logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until) + # So does a debug session the request names (E16): only the caller's own open one counts, and a + # header naming anything else is ignored rather than refused — a stale tab must keep working. + session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER)) + level, until = debugsession.level_for(user, session) + logs.set_user(user.id, level, until, session.id if session else None) request.state.user_id = user.id return user diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py new file mode 100644 index 00000000..5aaa847e --- /dev/null +++ b/backend/app/debugsession.py @@ -0,0 +1,261 @@ +"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from. + +While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth +dependency accepts the header only for the caller's OWN open session (anything else is ignored, never +an error — a stale tab must not break the app) and then logs that request at the session's level with +every line tagged by its id; the browser tags its own events the same way. The issue is later built +from exactly the rows carrying the tag, so what the review list shows is what gets sent. + +Starting a session IS the consent for its level, for its duration: the start dialog carries the +consent text. It needs no change to the user's own diagnostics level, and none is made — when the +session ends the level in force is simply the user's own again. + +A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over +whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the +hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`. + +Core layer: imports `models`, `diagnostics`. +""" + +import secrets +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.orm import Session, defer + +from app import diagnostics +from app.models import DebugScreenshot, DebugSession, LogEvent, User + +HEADER = "x-debug-session" +MAX_MINUTES = 60 +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 +# bundle says how many it left out rather than growing without bound. +BUNDLE_MAX = 5_000 + +SessionLevel = Literal["DEBUG", "TRACE"] +SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel) +# Detail order of the diagnostics levels: a session raises the level in force, never lowers it. +_DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2} +_ID_LEN = 16 + + +class EndReason(StrEnum): + STOPPED = "stopped" + EXPIRED = "expired" + + +def _is_live(session: DebugSession, now: datetime) -> bool: + return session.ended_at is None and session.until > now + + +def open_session(db: Session, user: User) -> DebugSession | None: + """The user's live session, or None. Read-only: one past its cap is over and reads as None; + `start` and the hourly sweep are what close it.""" + session = _unclosed(db, user) + if session is None or not _is_live(session, datetime.now(timezone.utc)): + return None + return session + + +def _unclosed(db: Session, user: User) -> DebugSession | None: + return db.scalar( + select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None)) + ) + + +def start(db: Session, user: User, level: SessionLevel) -> DebugSession: + """Open a session, or return the live one — a second tab pressing Start joins the recording + rather than ending it. One past its cap is closed first: the one-open-per-user index would + refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both + get here: the index refuses the second, and the caller retries once.""" + now = datetime.now(timezone.utc) + current = _unclosed(db, user) + if current is not None: + if _is_live(current, now): + return current + end(current, EndReason.EXPIRED) + db.flush() + session = DebugSession( + id=secrets.token_hex(_ID_LEN // 2), + user_id=user.id, + level=level, + started_at=now, + until=now + timedelta(minutes=MAX_MINUTES), + ) + db.add(session) + db.flush() + return session + + +def end(session: DebugSession, reason: EndReason) -> None: + """Close a session. An expired one is dated at its cap, not at whenever it was noticed.""" + if session.ended_at is not None: + return + session.ended_at = ( + session.until + if reason is EndReason.EXPIRED + else min(datetime.now(timezone.utc), session.until) + ) + session.ended_reason = reason + + +def from_header(db: Session, user: User, value: str | None) -> DebugSession | None: + """The session a request names, if it is the caller's own and still live; else None.""" + if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value): + return None + session = db.get(DebugSession, value) + if ( + session is None + or session.user_id != user.id + or not _is_live(session, datetime.now(timezone.utc)) + ): + return None + return session + + +def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]: + """The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the + session's while one is named — whichever records more.""" + own = diagnostics.effective_level(user) + until = user.diagnostics_until if own != diagnostics.BASE else None + if session is None or _DETAIL[session.level] <= _DETAIL[own]: + return own, until + return session.level, session.until + + +def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None: + if session is None: + return None + return { + "id": session.id, + "level": session.level, + "started_at": session.started_at.isoformat(), + "until": session.until.isoformat(), + "ended_at": session.ended_at.isoformat() if session.ended_at else None, + "ended_reason": session.ended_reason, + "issue_url": session.issue_url, + "screenshots": screenshots, + } + + +def screenshot_count(db: Session, session: DebugSession) -> int: + return ( + db.scalar( + select(func.count()) + .select_from(DebugScreenshot) + .where(DebugScreenshot.session_id == session.id) + ) + or 0 + ) + + +# --- screenshots --------------------------------------------------------------------------- +# By content, not by the declared type: what is stored is served back to a browser as an image. +_SIGNATURES = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), +) + + +def sniff(data: bytes) -> str | None: + """The image type `data` really is — PNG, JPEG or WebP — or None.""" + for magic, mime in _SIGNATURES: + if data.startswith(magic): + return mime + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + +class ScreenshotRefused(Exception): + """A screenshot that cannot be kept; `code` is the refusal's machine word.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot: + """Keep one screenshot. Flushes; the caller commits. Refused once the issue was sent (it can no + longer carry it), past the per-session count, or when it is not an image we can show.""" + if session.issue_url is not None: + raise ScreenshotRefused("already_sent") + if not data: + raise ScreenshotRefused("empty") + mime = sniff(data) + if mime is None: + raise ScreenshotRefused("not_an_image") + if screenshot_count(db, session) >= SCREENSHOTS_MAX: + raise ScreenshotRefused("too_many") + shot = DebugScreenshot( + session_id=session.id, + created_at=datetime.now(timezone.utc), + mime=mime, + size=len(data), + data=data, + ) + db.add(shot) + db.flush() + return shot + + +def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]: + """The session's screenshots, oldest first, WITHOUT their bytes loaded.""" + return list( + db.scalars( + select(DebugScreenshot) + .where(DebugScreenshot.session_id == session.id) + .options(defer(DebugScreenshot.data)) + .order_by(DebugScreenshot.id) + ) + ) + + +# --- the evidence -------------------------------------------------------------------------- +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) + .order_by(LogEvent.ts, LogEvent.id) + .limit(BUNDLE_MAX) + ) + ) + total = len(rows) + if total == BUNDLE_MAX: + total = ( + db.scalar( + select(func.count()) + .select_from(LogEvent) + .where(LogEvent.debug_session_id == session.id) + ) + or total + ) + return rows, total + + +# --- housekeeping (the hourly log job) ----------------------------------------------------- +def sweep(db: Session) -> int: + """Stage closing every open session past its cap; returns how many.""" + now = datetime.now(timezone.utc) + result = db.execute( + update(DebugSession) + .where(DebugSession.ended_at.is_(None), DebugSession.until <= now) + .values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value) + .execution_options(synchronize_session=False) + ) + return result.rowcount or 0 + + +def gc(db: Session, retention_days: int) -> int: + """Stage deleting sessions (and by cascade their screenshots) started before the log retention + window — the rows they would bundle are gone by then too. Returns how many.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days)) + result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff)) + return result.rowcount or 0 diff --git a/backend/app/logs.py b/backend/app/logs.py index 565030c8..61c2dcc6 100644 --- a/backend/app/logs.py +++ b/backend/app/logs.py @@ -100,12 +100,14 @@ class _RequestScope: request; every copied context shares the same object, so what the dependency writes is seen by the handler and by the request line itself.""" - __slots__ = ("user_id", "level") + __slots__ = ("user_id", "level", "debug_session_id") def __init__(self) -> None: self.user_id: int | None = None # The user's consented diagnostics level as a logging number; None at the base level. self.level: int | None = None + # The user's own open debug session this request named (E16), else None. + self.debug_session_id: str | None = None _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar( @@ -113,11 +115,19 @@ _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar( ) -def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None: +def set_user( + user_id: int | None, + level: str = "INFO", + until: datetime | None = None, + debug_session: str | None = None, +) -> None: """Attribute the rest of this request's log lines to `user_id`, and log them at the user's consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the auth dependency — the one place the session is actually resolved into an account — rather than - from the request middleware, which runs OUTSIDE the session middleware.""" + from the request middleware, which runs OUTSIDE the session middleware. + + `debug_session` tags every line with the user's open debug session (E16). The caller has + already checked it is theirs and open; a session's level arrives here as `level`/`until`.""" _user_id.set(user_id) scope = _scope.get() if scope is None: # outside log_requests (a script, a unit test): this context only @@ -125,6 +135,7 @@ def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = _scope.set(scope) elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc) scope.user_id = user_id + scope.debug_session_id = debug_session scope.level = resolve_level(level) if elevated else None if user_id is not None: _track_elevation(user_id, scope.level, until) @@ -328,6 +339,12 @@ class ContextFilter(logging.Filter): scope = _scope.get() if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"): record.user_id = scope.user_id + if ( + scope is not None + and scope.debug_session_id is not None + and not hasattr(record, "debug_session_id") + ): + record.debug_session_id = scope.debug_session_id return True diff --git a/backend/app/logsink.py b/backend/app/logsink.py index 924475ee..b07acd1c 100644 --- a/backend/app/logsink.py +++ b/backend/app/logsink.py @@ -44,6 +44,7 @@ from app import logs from app.config import settings from app.db import engine from app.models import LogEvent +from app.utils import iso QUEUE_MAX = 10_000 BATCH_MAX = 500 @@ -343,3 +344,24 @@ def gc(db: Session, retention_days: int) -> int: cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days)) result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff)) return result.rowcount or 0 + + +def serialize(row: LogEvent) -> dict: + """A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and + a debug session's evidence list (E16).""" + return { + "id": row.id, + "ts": iso(row.ts), + "level": row.level, + "source": row.source, + "logger": row.logger, + "event": row.event, + "msg": row.msg, + "attrs": row.attrs, + "exc": row.exc, + "request_id": row.request_id, + "user_id": row.user_id, + "job_id": row.job_id, + "debug_session_id": row.debug_session_id, + "version": row.version, + } diff --git a/backend/app/main.py b/backend/app/main.py index bf0e4708..c3856349 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,7 @@ from app.routes import ( auth as auth_routes, channels, config as config_routes, + debug_sessions as debug_sessions_routes, diagnostics as diagnostics_routes, discover as discover_routes, downloads, @@ -273,6 +274,7 @@ app.include_router(discover_routes.router) app.include_router(search_routes.router) app.include_router(me.router) app.include_router(diagnostics_routes.router) +app.include_router(debug_sessions_routes.router) app.include_router(notifications.router) app.include_router(messages.router) app.include_router(channels.router) diff --git a/backend/app/models.py b/backend/app/models.py index b4273184..a827d2d2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,6 +12,7 @@ from sqlalchemy import ( Index, Integer, JSON, + LargeBinary, SmallInteger, String, Text, @@ -565,7 +566,15 @@ class LogEvent(Base): the retention job (`log_retention_days`) bounds whatever another process writes late.""" __tablename__ = "log_events" - __table_args__ = (Index("ix_log_events_user_ts", "user_id", "ts"),) + __table_args__ = ( + Index("ix_log_events_user_ts", "user_id", "ts"), + # E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. + Index( + "ix_log_events_debug_session", + "debug_session_id", + postgresql_where="debug_session_id IS NOT NULL", + ), + ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) @@ -584,6 +593,50 @@ class LogEvent(Base): version: Mapped[str | None] = mapped_column(String(64)) +class DebugSession(Base): + """One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every + request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`, + and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the + server set at Start; an open session past it is over whether or not the sweep has closed it yet. + One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`).""" + + __tablename__ = "debug_sessions" + __table_args__ = ( + Index( + "uq_debug_sessions_open_per_user", + "user_id", + unique=True, + postgresql_where="ended_at IS NULL", + ), + ) + + id: Mapped[str] = mapped_column(String(16), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + level: Mapped[str] = mapped_column(String(8)) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + until: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # app/debugsession.py `EndReason`: stopped by the user, or past `until`. + ended_reason: Mapped[str | None] = mapped_column(String(16)) + issue_url: Mapped[str | None] = mapped_column(String(512)) + + +class DebugScreenshot(Base): + """A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill. + Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade.""" + + __tablename__ = "debug_screenshots" + + id: Mapped[int] = mapped_column(primary_key=True) + session_id: Mapped[str] = mapped_column( + ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + mime: Mapped[str] = mapped_column(String(16)) + size: Mapped[int] = mapped_column(Integer) + data: Mapped[bytes] = mapped_column(LargeBinary) + + class AppState(Base): """Single-row global app state (admin-controlled).""" diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index 6689f6fa..e8c530a1 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -8,7 +8,7 @@ one existence-leak policy. from typing import Any, TypeVar -from fastapi import HTTPException +from fastapi import HTTPException, Request from sqlalchemy.orm import Session from app.db import Base @@ -63,3 +63,15 @@ def youtube_refusal(exc: Exception) -> HTTPException: YouTube being unavailable to us — the retryable answer.""" reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable" return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason}) + + +async def capped_body(request: Request, limit: int) -> bytes: + """The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still + arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or + wrong, and counting stops reading at the same point a declared-length check would have.""" + body = bytearray() + async for chunk in request.stream(): + body += chunk + if len(body) > limit: + raise HTTPException(status_code=413, detail={"code": "too_large"}) + return bytes(body) diff --git a/backend/app/routes/admin_logs.py b/backend/app/routes/admin_logs.py index 43ae3c65..07b4c44f 100644 --- a/backend/app/routes/admin_logs.py +++ b/backend/app/routes/admin_logs.py @@ -26,10 +26,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Text, and_, cast, func, or_, select from sqlalchemy.orm import Session +from app import logsink from app.auth import admin_user from app.db import get_db from app.models import LogEvent, User -from app.utils import iso, like_escape +from app.utils import like_escape router = APIRouter(prefix="/api/admin/logs", tags=["admin"]) @@ -66,25 +67,6 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]: raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc -def _serialize(row: LogEvent) -> dict: - return { - "id": row.id, - "ts": iso(row.ts), - "level": row.level, - "source": row.source, - "logger": row.logger, - "event": row.event, - "msg": row.msg, - "attrs": row.attrs, - "exc": row.exc, - "request_id": row.request_id, - "user_id": row.user_id, - "job_id": row.job_id, - "debug_session_id": row.debug_session_id, - "version": row.version, - } - - def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]: """The accounts a page names, by id → address. An id with no account left (erased after the line was written) is simply absent, and the client shows the bare id.""" @@ -196,7 +178,7 @@ def list_logs( more = len(rows) > limit rows = rows[:limit] return { - "items": [_serialize(r) for r in rows], + "items": [logsink.serialize(r) for r in rows], "accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}), "next_cursor": _encode_cursor(rows[-1]) if more else None, } diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py new file mode 100644 index 00000000..a3722d74 --- /dev/null +++ b/backend/app/routes/debug_sessions.py @@ -0,0 +1,183 @@ +"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review. + +The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes +are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including +for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account +cannot record: its log would interleave every visitor under one account. + +Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart +form — one image per request, capped while it arrives, and kept only if its bytes really are one of +those types, whatever the Content-Type claimed. +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app import debugsession, logs, logsink +from app.auth import require_human +from app.db import get_db +from app.models import DebugScreenshot, DebugSession, User +from app.routes._common import capped_body, not_found, owned_or_404 +from app.utils import iso + +router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"]) +log = logs.get_logger(__name__) + +# Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft. +LIST_MAX = 20 + + +class StartIn(BaseModel): + level: debugsession.SessionLevel + + +def _view(db: Session, session: DebugSession) -> dict: + return debugsession.serialize(session, debugsession.screenshot_count(db, session)) or {} + + +@router.post("") +def start_session( + body: StartIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """Start recording, or join the recording already running (another tab started it).""" + try: + session = debugsession.start(db, user, body.level) + db.commit() + except IntegrityError: + # A concurrent Start won the one-open-per-user index: join that one. + db.rollback() + session = debugsession.open_session(db, user) + if session is None: + raise HTTPException(status_code=409, detail={"code": "session_conflict"}) + # The rest of THIS request is part of the session too. + level, until = debugsession.level_for(user, session) + logs.set_user(user.id, level, until, session.id) + log.info( + "debug session started", + extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)), + ) + return {"ok": True, "session": _view(db, session)} + + +@router.post("/{sid}/stop") +def stop_session( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + if session.ended_at is None: + # Logged BEFORE the session closes, while this request still carries its tag: the last line + # of the evidence is the Stop itself. + log.info("debug session stopped", extra=logs.event("debug.session.stopped")) + # Past its cap it had already ended on its own; a late Stop must not rename that. + live = session.until > datetime.now(timezone.utc) + reason = debugsession.EndReason.STOPPED if live else debugsession.EndReason.EXPIRED + debugsession.end(session, reason) + db.commit() + return {"ok": True, "session": _view(db, session)} + + +@router.get("") +def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: + sessions = db.scalars( + select(DebugSession) + .where(DebugSession.user_id == user.id) + .order_by(DebugSession.started_at.desc()) + .limit(LIST_MAX) + ).all() + return {"items": [_view(db, s) for s in sessions]} + + +@router.get("/{sid}") +def get_session( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session")) + + +@router.get("/{sid}/events") +def session_events( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + """The rows an issue from this session would carry — browser and server, in time order.""" + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + rows, total = debugsession.bundle(db, session) + return {"items": [logsink.serialize(r) for r in rows], "total": total} + + +# --- screenshots --------------------------------------------------------------------------- +def _shot_view(shot: DebugScreenshot) -> dict: + return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)} + + +@router.get("/{sid}/screenshots") +def list_screenshots( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]} + + +def _upload_target( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> DebugSession: + return owned_or_404(db, DebugSession, sid, user, what="debug_session") + + +async def _image_body(request: Request) -> bytes: + return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES) + + +@router.post("/{sid}/screenshots") +def add_screenshot( + # The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused + # before a byte of it is read. + session: DebugSession = Depends(_upload_target), + data: bytes = Depends(_image_body), + db: Session = Depends(get_db), +) -> dict: + try: + shot = debugsession.add_screenshot(db, session, data) + except debugsession.ScreenshotRefused as exc: + raise HTTPException( + status_code=422, detail={"code": "screenshot_refused", "reason": exc.code} + ) from exc + db.commit() + return {"ok": True, "screenshot": _shot_view(shot)} + + +def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot: + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + shot = db.get(DebugScreenshot, shot_id) + if shot is None or shot.session_id != session.id: + raise not_found("screenshot") + return shot + + +@router.get("/{sid}/screenshots/{shot_id}") +def get_screenshot( + sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> Response: + shot = _session_screenshot(db, sid, shot_id, user) + # `nosniff`: the type was checked against the bytes at upload, and the browser must not guess. + return Response( + content=shot.data, + media_type=shot.mime, + headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"}, + ) + + +@router.delete("/{sid}/screenshots/{shot_id}") +def delete_screenshot( + sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + shot = _session_screenshot(db, sid, shot_id, user) + db.delete(shot) + db.commit() + return {"ok": True, "deleted": shot_id} diff --git a/backend/app/routes/diagnostics.py b/backend/app/routes/diagnostics.py index 2cf62804..bdcfc9b9 100644 --- a/backend/app/routes/diagnostics.py +++ b/backend/app/routes/diagnostics.py @@ -13,7 +13,9 @@ Nothing from the body is trusted as-is: * everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a modified client would not; * an event below the consented level is dropped (a Részletes consent does not store what only - Teljes may carry), and so is the whole batch once the level has lapsed; + Teljes may carry), and so is the whole batch once the level has lapsed — where an open debug + session the request names counts as consent to ITS level (E16), and only its own events keep + its tag; * the timestamp is clamped to the consent window, so a row cannot dodge the retention job by claiming to come from the future. """ @@ -27,11 +29,12 @@ from pydantic import BaseModel, Field, ValidationError from sqlalchemy import insert from sqlalchemy.orm import Session -from app import diagnostics, logs, logsink +from app import debugsession, diagnostics, logs, logsink from app.auth import current_user from app.db import get_db -from app.models import LogEvent, User +from app.models import DebugSession, LogEvent, User from app.ratelimit import RateLimiter +from app.routes._common import capped_body router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"]) @@ -60,6 +63,9 @@ class ClientEvent(BaseModel): attrs: dict[str, Any] | None = None request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$") tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$") + # The debug session the event was captured in (E16); kept only when it is the one this request + # names in `X-Debug-Session` and the auth dependency found it the caller's own and open. + debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$") version: str | None = Field(default=None, max_length=64) @@ -67,18 +73,6 @@ class ClientEventsIn(BaseModel): events: list[ClientEvent] = Field(max_length=EVENTS_MAX) -async def _capped_body(request: Request) -> bytes: - """The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted, - not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting - stops reading at the same point a declared-length check would have.""" - body = bytearray() - async for chunk in request.stream(): - body += chunk - if len(body) > BODY_MAX_BYTES: - raise HTTPException(status_code=413, detail={"code": "too_large"}) - return bytes(body) - - async def _parsed_events(request: Request) -> ClientEventsIn: """The body as the model — parsed only after the size cap, which is why this is a dependency and not a plain body parameter (FastAPI reads a declared body in full before anything else runs). @@ -89,14 +83,20 @@ async def _parsed_events(request: Request) -> ClientEventsIn: media_type = request.headers.get("content-type", "").split(";")[0].strip().lower() if media_type != "application/json" and not media_type.endswith("+json"): raise HTTPException(status_code=415, detail={"code": "json_required"}) - raw = await _capped_body(request) + raw = await capped_body(request, BODY_MAX_BYTES) try: return ClientEventsIn.model_validate_json(raw) except ValidationError as exc: raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc -def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]: +def _row( + item: ClientEvent, + user: User, + now: datetime, + window_start: datetime, + session: DebugSession | None, +) -> dict[str, Any]: attrs = logs.redact(item.attrs) if item.attrs else None if attrs is not None: size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode()) @@ -117,6 +117,9 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) - "attrs": attrs, "request_id": item.request_id, "user_id": user.id, + "debug_session_id": ( + session.id if session and item.debug_session_id == session.id else None + ), "version": item.version, } ) @@ -126,11 +129,15 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) - def ingest_events( # The account first: dependencies resolve in order, so an anonymous caller is refused before a # byte of its body is read. + request: Request, user: User = Depends(current_user), body: ClientEventsIn = Depends(_parsed_events), db: Session = Depends(get_db), ) -> dict: - level = diagnostics.effective_level(user) + # A debug session the request names raises the level for its own events (E16): starting one is + # the consent to that level for its duration, whatever the user's own level is. + session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER)) + level, _ = debugsession.level_for(user, session) if level == diagnostics.BASE: # Lapsed or withdrawn: the client stops streaming when it sees this. raise HTTPException(status_code=409, detail={"code": "diagnostics_off"}) @@ -140,7 +147,7 @@ def ingest_events( window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS) floor = logs.SEVERITY[level] rows = [ - _row(item, user, now, window_start) + _row(item, user, now, window_start, session) for item in body.events if logs.SEVERITY[item.level] >= floor ] diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 6f1784cf..34bf9cc0 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -17,7 +17,7 @@ from app.auth import ( optional_current_user, purge_user, ) -from app import diagnostics +from app import debugsession, diagnostics from app import email as email_mod from app import logs from app import sysconfig @@ -158,9 +158,18 @@ def get_me( # The consented diagnostics level in force and when it lapses (E22 S5). The client applies # it to its own log and streams that log while it is raised. "diagnostics": diagnostics.state(db, user), + # The live debug session, if one is recording (E16) — a reload resumes it. + "debug_session": _live_session(db, user), } +def _live_session(db: Session, user: User) -> dict | None: + session = debugsession.open_session(db, user) + if session is None: + return None + return debugsession.serialize(session, debugsession.screenshot_count(db, session)) + + @router.delete("/account") def delete_my_account( request: Request, diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index c3e24d12..156fbe21 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -9,7 +9,7 @@ from typing import Callable from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy import select -from app import audit, logs, logsink, progress, quota +from app import audit, debugsession, logs, logsink, progress, quota from app.audit import AuditAction from app.config import settings from app.db import SessionLocal @@ -386,9 +386,13 @@ def _log_gc_job() -> None: def work(db): from app import sysconfig - reaped = logsink.gc(db, sysconfig.get_int(db, "log_retention_days")) + days = sysconfig.get_int(db, "log_retention_days") + reaped = logsink.gc(db, days) + # Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed. + closed = debugsession.sweep(db) + sessions = debugsession.gc(db, days) db.commit() - return {"reaped": reaped} + return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions} _job("log_gc", work) diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py new file mode 100644 index 00000000..50a9bc96 --- /dev/null +++ b/backend/tests/test_debug_sessions.py @@ -0,0 +1,438 @@ +"""E16 S1 — debug sessions: Start → reproduce → Stop, the tag on every line, the screenshots. + +Four parts: + * the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it + on its own, the demo account cannot record, nobody reaches another user's session (DB lane); + * the tag: a request naming the caller's own open session is logged at its level with every line + tagged, while a header naming anything else is ignored (a real sign-in through `_session_user`); + * the browser's batches: a session counts as consent to its level, and only its own events keep + its tag (DB lane); + * screenshots: judged by their bytes, capped in size and count, served back only to their owner. +""" + +import logging +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi import BackgroundTasks +from sqlalchemy import select + +from app import debugsession, logs +from app.models import DebugScreenshot, DebugSession, LogEvent, User +from app.routes import diagnostics as diagnostics_routes + +_PW = "correct horse battery" +_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 +_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 32 +_WEBP = b"RIFF\x00\x00\x00\x00WEBPVP8 " + b"\x00" * 16 + + +def _signed_in(client, make_user, email="rec@example.com", **kw) -> User: + """A real sign-in, so the auth dependency itself reads `X-Debug-Session` (`as_user` bypasses + it).""" + user = make_user(email=email, password=_PW, **kw) + r = client.post("/auth/password-login", json={"email": email, "password": _PW}) + assert r.status_code == 200, r.text + return user + + +def _start(client, level="DEBUG") -> dict: + r = client.post("/api/debug-sessions", json={"level": level}) + assert r.status_code == 200, r.text + return r.json()["session"] + + +# --- the session's life --------------------------------------------------------------------- + + +def test_start_opens_a_capped_session_that_me_reports(db, client, make_user): + _signed_in(client, make_user) + session = _start(client, "TRACE") + assert session["level"] == "TRACE" and session["ended_at"] is None + started = datetime.fromisoformat(session["started_at"]) + until = datetime.fromisoformat(session["until"]) + assert until - started == timedelta(minutes=debugsession.MAX_MINUTES) + assert client.get("/api/me").json()["debug_session"]["id"] == session["id"] + + +def test_a_second_start_joins_the_running_session(db, make_user, as_user): + client = as_user(make_user()) + first = _start(client, "DEBUG") + assert _start(client, "TRACE")["id"] == first["id"] + assert db.scalar(select(DebugSession).where(DebugSession.id == first["id"])).level == "DEBUG" + + +def test_only_the_two_raised_levels_can_be_recorded(db, make_user, as_user): + client = as_user(make_user()) + for bogus in ("INFO", "WARNING", "trace"): + assert client.post("/api/debug-sessions", json={"level": bogus}).status_code == 422 + + +def test_the_demo_account_cannot_record(db, make_user, as_user): + r = as_user(make_user(is_demo=True)).post("/api/debug-sessions", json={"level": "DEBUG"}) + assert r.status_code == 403 and r.json()["detail"]["code"] == "demo" + + +def test_stop_ends_it_and_me_stops_reporting_it(db, client, make_user): + _signed_in(client, make_user) + sid = _start(client)["id"] + r = client.post(f"/api/debug-sessions/{sid}/stop") + assert r.status_code == 200 and r.json()["session"]["ended_reason"] == "stopped" + assert client.get("/api/me").json()["debug_session"] is None + # A second Stop changes nothing. + again = client.post(f"/api/debug-sessions/{sid}/stop").json()["session"] + assert again["ended_at"] == r.json()["session"]["ended_at"] + + +def _expire(db, sid: str) -> None: + session = db.get(DebugSession, sid) + session.started_at -= timedelta(hours=2) + session.until = datetime.now(timezone.utc) - timedelta(minutes=1) + db.commit() + + +def test_a_session_past_its_cap_is_over_before_anything_closes_it(db, client, make_user): + _signed_in(client, make_user) + sid = _start(client)["id"] + _expire(db, sid) + assert client.get("/api/me").json()["debug_session"] is None + # A late Stop records that it ended on its own, at its cap. + stopped = client.post(f"/api/debug-sessions/{sid}/stop").json()["session"] + assert stopped["ended_reason"] == "expired" + assert stopped["ended_at"] == stopped["until"] + + +def test_a_start_after_an_expired_session_closes_it_and_opens_a_new_one(db, make_user, as_user): + client = as_user(make_user()) + old = _start(client)["id"] + _expire(db, old) + new = _start(client)["id"] + assert new != old + db.expire_all() + assert db.get(DebugSession, old).ended_reason == "expired" + + +def test_nobody_reaches_another_users_session(db, make_user, as_user): + owner, stranger = as_user(make_user()), as_user(make_user()) + sid = _start(owner)["id"] + shot = owner.post( + f"/api/debug-sessions/{sid}/screenshots", + content=_PNG, + headers={"content-type": "image/png"}, + ).json()["screenshot"]["id"] + for method, path in ( + ("get", f"/api/debug-sessions/{sid}"), + ("post", f"/api/debug-sessions/{sid}/stop"), + ("get", f"/api/debug-sessions/{sid}/events"), + ("get", f"/api/debug-sessions/{sid}/screenshots"), + ("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"), + ("delete", f"/api/debug-sessions/{sid}/screenshots/{shot}"), + ): + r = getattr(stranger, method)(path) + assert r.status_code == 404, (method, path, r.status_code) + assert r.json()["detail"] == {"code": "not_found", "reason": "debug_session"} + r = stranger.post( + f"/api/debug-sessions/{sid}/screenshots", + content=_PNG, + headers={"content-type": "image/png"}, + ) + assert r.status_code == 404 + assert stranger.get("/api/debug-sessions").json()["items"] == [] + db.expire_all() + assert db.get(DebugSession, sid).ended_at is None + + +def test_the_list_is_the_users_own_newest_first(db, make_user, as_user): + client = as_user(make_user()) + first = _start(client)["id"] + client.post(f"/api/debug-sessions/{first}/stop") + second = _start(client)["id"] + assert [s["id"] for s in client.get("/api/debug-sessions").json()["items"]] == [second, first] + + +def test_the_hourly_job_closes_expired_sessions_and_drops_old_ones(db, make_user): + user = make_user() + now = datetime.now(timezone.utc) + lapsed = DebugSession( + id="b" * 16, + user_id=user.id, + level="DEBUG", + started_at=now - timedelta(hours=2), + until=now - timedelta(hours=1), + ) + db.add(lapsed) + ancient = DebugSession( + id="c" * 16, + user_id=user.id, + level="DEBUG", + started_at=now - timedelta(days=30), + until=now - timedelta(days=30) + timedelta(hours=1), + ended_at=now - timedelta(days=30), + ended_reason="stopped", + ) + db.add(ancient) + db.flush() # no ORM relationship orders the inserts: the session row first + db.add( + DebugScreenshot(session_id=ancient.id, created_at=now, mime="image/png", size=4, data=b"x") + ) + db.commit() + + assert debugsession.sweep(db) == 1 + assert debugsession.gc(db, 7) == 1 + db.commit() + db.expire_all() + assert db.get(DebugSession, lapsed.id).ended_reason == "expired" + assert db.get(DebugSession, lapsed.id).ended_at == lapsed.until + assert db.get(DebugSession, ancient.id) is None + assert db.scalars(select(DebugScreenshot)).all() == [] + + +def test_an_erased_account_takes_its_sessions_with_it(db, make_user, as_user): + from app.auth import purge_user + + user = make_user() + client = as_user(user) + sid = _start(client)["id"] + client.post( + f"/api/debug-sessions/{sid}/screenshots", + content=_PNG, + headers={"content-type": "image/png"}, + ) + purge_user(db, user, BackgroundTasks()) + assert db.get(DebugSession, sid) is None + assert db.scalars(select(DebugScreenshot)).all() == [] + + +# --- the tag -------------------------------------------------------------------------------- + + +class _Capture(logging.Handler): + def __init__(self): + super().__init__(level=logging.NOTSET) + self.records: list[logging.LogRecord] = [] + self.addFilter(logs.ContextFilter()) + self.addFilter(logs.LevelGate()) + + def emit(self, record): + self.records.append(record) + + +@pytest.fixture +def gated(): + """The process at INFO with a gated handler on the root, as test_diagnostics' fixture.""" + root = logging.getLogger() + saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level) + handler = _Capture() + root.addHandler(handler) + logs._elevations.clear() + logs.set_level("INFO") + try: + yield handler + finally: + root.removeHandler(handler) + logs._base_level, logs._root_applied = saved[0], saved[2] + logs._elevations.clear() + logs._elevations.update(saved[1]) + root.setLevel(saved[3]) + + +def _request_lines(handler: _Capture) -> list[logging.LogRecord]: + return [r for r in handler.records if getattr(r, "event", None) == "http.request"] + + +def test_a_request_naming_the_own_open_session_is_tagged_and_raised(gated, db, client, make_user): + _signed_in(client, make_user) + sid = _start(client, "DEBUG")["id"] + gated.records.clear() + client.get("/api/debug-sessions", headers={"x-debug-session": sid}) + (line,) = _request_lines(gated) + assert getattr(line, "debug_session_id", None) == sid + # Raised to the session's level: the line is kept for storage though the user's own is Alap. + assert getattr(line, "elevated", False) is True + + +def test_a_header_naming_anything_else_is_ignored_not_refused(gated, db, client, make_user): + other = make_user(email="other@example.com") + foreign = debugsession.start(db, other, "TRACE").id + db.commit() + _signed_in(client, make_user) + own = _start(client)["id"] + client.post(f"/api/debug-sessions/{own}/stop") + for value in (foreign, own, "not-a-session", "f" * 16, "A" * 16): + gated.records.clear() + # Not `/api/me`: a polled route logs at DEBUG, and an un-raised request would drop the very + # line this checks. + r = client.get("/api/debug-sessions", headers={"x-debug-session": value}) + assert r.status_code == 200, value + (line,) = _request_lines(gated) + assert getattr(line, "debug_session_id", None) is None, value + assert getattr(line, "elevated", False) is False, value + + +def test_a_session_never_lowers_the_users_own_level(): + now = datetime.now(timezone.utc) + user = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(hours=3)) + session = DebugSession(level="DEBUG", until=now + timedelta(minutes=30)) + assert debugsession.level_for(user, session) == ("TRACE", user.diagnostics_until) + assert debugsession.level_for(User(), session) == ("DEBUG", session.until) + assert debugsession.level_for(User(), None) == ("INFO", None) + + +# --- the browser's batches ------------------------------------------------------------------- + + +def _event(**over) -> dict: + return { + "ts": datetime.now(timezone.utc).isoformat(), + "level": "DEBUG", + "event": "ui.click", + "msg": "click", + "tab_id": "a1b2c3", + **over, + } + + +@pytest.fixture +def batches(): + diagnostics_routes._batches.reset() + yield + diagnostics_routes._batches.reset() + + +def test_a_session_lets_its_own_events_in_at_its_level_and_tags_them( + batches, db, make_user, as_user +): + user = make_user() + client = as_user(user) + sid = _start(client, "DEBUG")["id"] + other = debugsession.start(db, make_user(), "DEBUG").id + db.commit() + events = [ + _event(debug_session_id=sid), + _event(debug_session_id=other), # not this session's: kept, untagged + _event(), # captured before Start: kept, untagged + _event(level="TRACE", debug_session_id=sid), # under the session's level: dropped + ] + r = client.post( + "/api/diagnostics/events", json={"events": events}, headers={"x-debug-session": sid} + ) + assert r.json() == {"ok": True, "stored": 3} + rows = db.scalars(select(LogEvent).order_by(LogEvent.id)).all() + assert [row.debug_session_id for row in rows] == [sid, None, None] + assert {row.user_id for row in rows} == {user.id} + + +def test_without_the_header_a_base_level_user_is_still_refused(batches, db, make_user, as_user): + client = as_user(make_user()) + sid = _start(client)["id"] + r = client.post("/api/diagnostics/events", json={"events": [_event(debug_session_id=sid)]}) + assert r.status_code == 409 and r.json()["detail"]["code"] == "diagnostics_off" + + +def test_the_evidence_is_the_sessions_rows_in_time_order(db, make_user, as_user): + user = make_user() + client = as_user(user) + sid = _start(client)["id"] + now = datetime.now(timezone.utc) + + def row(ts, source, session): + return LogEvent( + ts=ts, + level="INFO", + sev=9, + source=source, + logger=source, + event="x.y", + msg=source, + user_id=user.id, + debug_session_id=session, + ) + + db.add_all( + [ + row(now + timedelta(seconds=2), "api", sid), + row(now + timedelta(seconds=1), "web", sid), + row(now, "web", 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 body["total"] == 2 + + +# --- screenshots ---------------------------------------------------------------------------- + + +@pytest.fixture +def recording(db, make_user, as_user): + client = as_user(make_user()) + return client, _start(client)["id"] + + +def _upload(client, sid, data, content_type="image/png"): + return client.post( + f"/api/debug-sessions/{sid}/screenshots", + content=data, + headers={"content-type": content_type}, + ) + + +@pytest.mark.parametrize( + ("data", "mime"), [(_PNG, "image/png"), (_JPEG, "image/jpeg"), (_WEBP, "image/webp")] +) +def test_a_screenshot_is_kept_as_what_its_bytes_are(recording, data, mime): + client, sid = recording + # The declared type is not trusted: every one of these claims to be a PNG. + r = _upload(client, sid, data) + assert r.status_code == 200 and r.json()["screenshot"]["mime"] == mime + shot = r.json()["screenshot"]["id"] + got = client.get(f"/api/debug-sessions/{sid}/screenshots/{shot}") + assert got.content == data + assert got.headers["content-type"] == mime + assert got.headers["x-content-type-options"] == "nosniff" + + +@pytest.mark.parametrize( + ("data", "reason"), [(b"", "not_an_image"), (b"", "empty")] +) +def test_what_is_not_an_image_is_refused(recording, data, reason): + client, sid = recording + r = _upload(client, sid, data) + assert r.status_code == 422 + assert r.json()["detail"] == {"code": "screenshot_refused", "reason": reason} + + +def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch): + client, sid = recording + monkeypatch.setattr(debugsession, "SCREENSHOT_MAX_BYTES", len(_PNG) + 1) + assert _upload(client, sid, _PNG + b"\x00\x00").status_code == 413 + for _ in range(debugsession.SCREENSHOTS_MAX): + assert _upload(client, sid, _PNG).status_code == 200 + r = _upload(client, sid, _PNG) + assert r.status_code == 422 and r.json()["detail"]["reason"] == "too_many" + assert ( + client.get(f"/api/debug-sessions/{sid}").json()["screenshots"] + == debugsession.SCREENSHOTS_MAX + ) + + +def test_a_sent_session_takes_no_more_screenshots(db, recording): + client, sid = recording + db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1" + db.commit() + r = _upload(client, sid, _PNG) + assert r.status_code == 422 and r.json()["detail"]["reason"] == "already_sent" + + +def test_a_screenshot_can_be_removed_and_is_then_gone(recording): + client, sid = recording + shot = _upload(client, sid, _PNG).json()["screenshot"]["id"] + assert client.get(f"/api/debug-sessions/{sid}/screenshots").json()["items"][0]["id"] == shot + r = client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}") + 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" diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 4e7f131b..5d123be6 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -20,6 +20,8 @@ "recipient": "There's no user with that email address.", "job": "This job doesn't exist any more.", "download": "This download doesn't exist any more.", + "debug_session": "This debug session doesn't exist any more.", + "screenshot": "This screenshot doesn't exist any more.", "link": "This share link doesn't exist any more.", "notification": "This notification doesn't exist any more.", "account": "That account no longer exists.", diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 57d74321..3f932915 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -20,6 +20,8 @@ "recipient": "Nincs felhasználó ezzel az e-mail címmel.", "job": "Ez a feladat már nem létezik.", "download": "Ez a letöltés már nem létezik.", + "debug_session": "Ez a hibakereső munkamenet már nem létezik.", + "screenshot": "Ez a képernyőkép már nem létezik.", "link": "Ez a megosztási link már nem létezik.", "notification": "Ez az értesítés már nem létezik.", "account": "Ez a fiók már nem létezik.", From 2d9595b0e53b2321c02989d6deca4d6a6dd3f9b3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:08:10 +0200 Subject: [PATCH 03/14] feat(ui): a control can dock under the toasts and ride every toast host The toast stack already has a host in the page and in every overlay (dialogs, both players, the fullscreen player), the topmost one shows it, and inside an overlay it sits above the player's control band. lib/toastDock lets one control use exactly that placement; it renders outside the live region, so a ticking control is not read out every second. --- .../src/components/ui/Toaster.dom.test.tsx | 4 +- frontend/src/components/ui/Toaster.tsx | 123 ++++++++++-------- frontend/src/lib/toastDock.ts | 27 ++++ 3 files changed, 101 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/toastDock.ts diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx index 149bd759..3188b412 100644 --- a/frontend/src/components/ui/Toaster.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.dom.test.tsx @@ -83,7 +83,7 @@ describe("Toaster — handing the toasts to another host", () => { render(); act(() => void notify({ level: "info", message: "saved" })); const overlay = render(); - const region = overlay.container.firstElementChild!; + const region = overlay.container.querySelector("[aria-live]")!; expect(region.textContent).toContain("saved"); // it took the toast over… expect(region.getAttribute("aria-live")).toBe("off"); // …while still off await act(async () => void (await new Promise((r) => setTimeout(r, 0)))); @@ -170,7 +170,7 @@ describe("Toaster — handing the toasts to another host", () => { // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up. render(); act(() => void notify({ level: "error", message: "boom" })); - const host = hosts()[0]!; + const host = hosts()[0]!.closest("[data-toast-region]")!; expect(host.className).toContain("bottom-24"); expect(host.className).toContain("pointer-events-none"); expect(screen.getByText("boom").closest(".toast-card")!.className).toContain( diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx index 2afccd73..b3e4b4fb 100644 --- a/frontend/src/components/ui/Toaster.tsx +++ b/frontend/src/components/ui/Toaster.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { createStore } from "../../lib/store"; +import { useToastDock } from "../../lib/toastDock"; import { useOverlayOrder } from "./overlayOrder"; import { LEVEL_STYLE } from "./toastLevels"; @@ -51,6 +52,7 @@ export default function Toaster({ }, [overlay, id, rank]); const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost); const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); + const Docked = useToastDock(); const isTop = top === (overlay ? id : null); const toasts = isTop ? active : []; // A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts @@ -113,68 +115,87 @@ export default function Toaster({ // uses for the same reason. Clicks between the cards go through to what is underneath. // Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right // of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19). + // + // Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top + // host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
e.stopPropagation() : undefined} > - {toasts.map((toast) => { - const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; - const urgent = toast.level === "error" || toast.level === "fatal"; - return ( -
- -
- {toast.title &&
{toast.title}
} -
- {toast.message} - {toast.repeat > 1 && ( - - ×{toast.repeat} - +
+ {toasts.map((toast) => { + const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; + const urgent = toast.level === "error" || toast.level === "fatal"; + return ( +
+ +
+ {toast.title &&
{toast.title}
} +
+ {toast.message} + {toast.repeat > 1 && ( + + ×{toast.repeat} + + )} +
+ {toast.action && ( + )}
- {toast.action && ( - + + {toast.duration && ( + // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. + )}
- - {toast.duration && ( - // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. - - )} -
- ); - })} + ); + })} +
+ {isTop && Docked && ( +
+ +
+ )}
); } diff --git a/frontend/src/lib/toastDock.ts b/frontend/src/lib/toastDock.ts new file mode 100644 index 00000000..35665d5a --- /dev/null +++ b/frontend/src/lib/toastDock.ts @@ -0,0 +1,27 @@ +import type { ComponentType } from "react"; +import { createStore } from "./store"; + +// A control that lives where the toasts live, under them (E16: the debug recorder's pill). +// +// The toast stack already solves the hard part of "always visible, never in the way": it has a host +// in the page and one inside every overlay — the dialogs, both players, the fullscreen player — the +// topmost host shows it, and inside an overlay it sits above the player's control band. A control +// that must stay reachable while the user reproduces a bug anywhere needs exactly that, so it rides +// the same hosts instead of rebuilding them: `ui/Toaster` renders whatever is docked here below its +// stack, in the host that is on top. +// +// One slot, not a list: one surface uses it, and a second would have to decide how they share the +// corner before this grows. +const docked = createStore(null); + +/** Dock `component` under the toasts until the returned function undocks it. */ +export function dockUnderToasts(component: ComponentType): () => void { + // Always the updater form: a component IS a function, so `set(component)` would be taken for one + // and called with the previous value instead of stored. + docked.set(() => component); + return () => + docked.set((current: ComponentType | null) => (current === component ? null : current)); +} + +/** What is docked (the Toaster reads it). */ +export const useToastDock = (): ComponentType | null => docked.use(); From 46fce4a76790c91f8c24f3112fffdf03056f57c1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:08:10 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat(diagnostics):=20the=20debug=20record?= =?UTF-8?q?er=20=E2=80=94=20start,=20the=20pill,=20steps,=20screenshots,?= =?UTF-8?q?=20stop=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
); } + +/** 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 ( + void q.refetch()}> + {t("recorder.history.failed")} + + ); + if (!q.data) return ; + return ( +
+
{t("recorder.history.title")}
+ {items.length === 0 ? ( +

{t("recorder.history.empty")}

+ ) : ( +
    + {items.map((s) => ( +
  • + + {new Date(s.started_at).toLocaleString(i18n.language)} + + + {s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)} + + + {t( + s.issue_url + ? "recorder.history.sent" + : s.draft + ? "recorder.history.draft" + : "recorder.history.unwritten", + )} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/IssueComposer.dom.test.tsx b/frontend/src/components/IssueComposer.dom.test.tsx new file mode 100644 index 00000000..5f206f8a --- /dev/null +++ b/frontend/src/components/IssueComposer.dom.test.tsx @@ -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(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + 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(); + 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 => ({ + 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 => ({ + 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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx new file mode 100644 index 00000000..2f44a867 --- /dev/null +++ b/frontend/src/components/IssueComposer.tsx @@ -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 ( + + {failed && !(sessionQ.data && eventsQ.data) ? ( + { + void sessionQ.refetch(); + void eventsQ.refetch(); + }} + > + {t("recorder.composer.loadFailed")} + + ) : !sessionQ.data || !eventsQ.data ? ( + + ) : ( + + )} + + ); +} + +/** 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 | 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( + () => + savedForm(session) ?? { + title: "", + description: "", + steps: draftSteps(rows, pageName, logClock), + excluded: [], + }, + ); + const [reviewing, setReviewing] = useState(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, + }), + 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 ( +
+

+ {t("recorder.composer.meta", { + start: started.toLocaleString(i18n.language), + minutes, + level: t(`settings.diagnostics.levels.${session.level}`), + })} +

+ {sent && ( +
+ {t("recorder.composer.sent")} + + {t("recorder.composer.openIssue")} + + +
+ )} + {reviewing ? ( + 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)} + /> + ) : ( +
+ + sourceOf(r) === "web").length], + server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length], + }} + readOnly={sent} + onReview={setReviewing} + /> +
+ )} +
+ + {save.isPending + ? t("recorder.composer.saving") + : save.isSuccess + ? t("recorder.composer.saved") + : ""} + + + +
+
+ ); +} + +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) => + edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); + return ( +
+
+ + edit((f) => ({ ...f, title: e.target.value }))} + className={inputCls} + /> +
+
+ +