From 8d0083d4e44948b3d8510f4297ed240ed24d3c16 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:26:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(diagnostics):=20the=20issue=20composer=20?= =?UTF-8?q?=E2=80=94=20draft,=20review,=20screenshots,=20zip=20export=20(E?= =?UTF-8?q?16=20S3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Stop (or at the cap, or from Settings → Diagnosztika's past sessions) one dialog drafts the issue: title, description, the steps drafted from the session's markers and page changes (a marker without a label is named by its time), the environment (version, browser, system, window, language). Beside it, what goes with it: the browser and server logs — each reviewable line by line, an unticked line is not sent — and the screenshots, removable, with paste / drop / pick to add more. - it saves as it goes (debug_sessions.draft, migration 0090): the rendered markdown in the user's language, the excluded rows, and its own fields to reopen as it was; a sent session is read-only - the zip export (issue.md, client.log, server.log, screenshots) is built from the saved draft by app/issues/bundle.py — the one definition the Gitea delivery (S4) will share; .log, not .jsonl, which Gitea refuses - the log line's level tags and clock move to components/logLineParts so the review list draws a level the way both log viewers do --- CLAUDE.md | 2 +- .../versions/0090_debug_session_draft.py | 27 + backend/app/README.md | 5 +- backend/app/debugsession.py | 29 +- backend/app/issues/__init__.py | 3 + backend/app/issues/bundle.py | 77 +++ backend/app/models.py | 2 + backend/app/routes/debug_sessions.py | 58 +- backend/tests/test_debug_sessions.py | 97 +++ .../src/components/DebugRecorder.dom.test.tsx | 1 + frontend/src/components/DebugRecorder.tsx | 76 ++- .../src/components/IssueComposer.dom.test.tsx | 225 +++++++ frontend/src/components/IssueComposer.tsx | 565 ++++++++++++++++++ frontend/src/components/LogLine.tsx | 17 +- frontend/src/components/logLineParts.ts | 21 + frontend/src/components/useDebugRecording.ts | 5 +- frontend/src/i18n/locales/en/recorder.json | 72 +++ frontend/src/i18n/locales/hu/recorder.json | 72 +++ frontend/src/lib/api/diagnostics.ts | 20 + .../src/lib/diagnostics.session.dom.test.ts | 1 + frontend/src/lib/issueDraft.test.ts | 128 ++++ frontend/src/lib/issueDraft.ts | 147 +++++ frontend/src/lib/queryKeys.ts | 5 + 23 files changed, 1620 insertions(+), 35 deletions(-) create mode 100644 backend/alembic/versions/0090_debug_session_draft.py create mode 100644 backend/app/issues/__init__.py create mode 100644 backend/app/issues/bundle.py create mode 100644 frontend/src/components/IssueComposer.dom.test.tsx create mode 100644 frontend/src/components/IssueComposer.tsx create mode 100644 frontend/src/components/logLineParts.ts create mode 100644 frontend/src/lib/issueDraft.test.ts create mode 100644 frontend/src/lib/issueDraft.ts diff --git a/CLAUDE.md b/CLAUDE.md index f1fae3cd..84c8184f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ how it gets built. Concretely: ## Architecture shape (don't fight it) `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, -`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, +`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `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 diff --git a/backend/alembic/versions/0090_debug_session_draft.py b/backend/alembic/versions/0090_debug_session_draft.py new file mode 100644 index 00000000..6a021000 --- /dev/null +++ b/backend/alembic/versions/0090_debug_session_draft.py @@ -0,0 +1,27 @@ +"""add debug_sessions.draft — the issue being written from a session (E16 S3) + +Revision ID: 0090_debug_session_draft +Revises: 0089_debug_sessions +Create Date: 2026-09-19 + +The composer saves as it goes: the title, the rendered markdown body that an export or an issue +carries, the log rows the user took out, and the form's own fields so reopening it later (Settings → +Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves. +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "0090_debug_session_draft" +down_revision: str | None = "0089_debug_sessions" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column("debug_sessions", "draft") diff --git a/backend/app/README.md b/backend/app/README.md index af8b21b3..9fb2cac1 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -13,11 +13,12 @@ Three layers, by *who* runs the code — not by feature: response models, status-code mapping, serialization. It owns logic that runs *only* on a request. Business logic that a request happens to trigger does **not** live here if anything else runs it too. -2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the +2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the **scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the route, the worker, and the scheduler share one definition. Examples from this codebase: `downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is - run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline. + run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline; + `issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker. 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py index 98a18025..cb385ff8 100644 --- a/backend/app/debugsession.py +++ b/backend/app/debugsession.py @@ -127,6 +127,13 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | return session.level, session.until +def save_draft(session: DebugSession, draft: dict) -> None: + """Keep the composer's draft (the caller validated and commits). Shape: `title`, `body` (the + rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the + evidence) and `form` (the composer's own fields, to reopen it as it was).""" + session.draft = draft + + def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None: if session is None: return None @@ -139,6 +146,7 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None "ended_reason": session.ended_reason, "issue_url": session.issue_url, "screenshots": screenshots, + "draft": session.draft, } @@ -231,23 +239,24 @@ def _evidence(session: DebugSession): return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True))) -def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]: +def bundle( + db: Session, session: DebugSession, *, drop_excluded: bool = False +) -> 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.""" + how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows + the user took out in the composer: what an export or an issue carries.""" + where = list(_evidence(session)) + excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else [] + if excluded: + where.append(LogEvent.id.not_in(excluded)) rows = list( db.scalars( - select(LogEvent) - .where(*_evidence(session)) - .order_by(LogEvent.ts, LogEvent.id) - .limit(BUNDLE_MAX) + select(LogEvent).where(*where).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(*_evidence(session))) - or total - ) + total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total return rows, total diff --git a/backend/app/issues/__init__.py b/backend/app/issues/__init__.py new file mode 100644 index 00000000..dd2ed760 --- /dev/null +++ b/backend/app/issues/__init__.py @@ -0,0 +1,3 @@ +"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go — +downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded +and tagged — is the core module `app/debugsession.py`.""" diff --git a/backend/app/issues/bundle.py b/backend/app/issues/bundle.py new file mode 100644 index 00000000..07d02a7d --- /dev/null +++ b/backend/app/issues/bundle.py @@ -0,0 +1,77 @@ +"""What an issue from a debug session carries, as files — one definition for the zip export and the +Gitea upload, so the two cannot drift apart. + + * `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there, + in their language; the server does not rewrite it); + * `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the + user took out, one JSON object per line, browser rows and server rows apart. `.log`, not + `.jsonl`: Gitea refuses that extension (measured, plans/e16 S0); + * the screenshots, in the order they were taken. + +Every row is already redacted twice (at capture, and again when stored), and nothing here adds a +field the review list did not show. +""" + +import io +import json +import zipfile +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app import debugsession, logsink +from app.models import DebugScreenshot, DebugSession + +_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"} + + +@dataclass(frozen=True) +class IssueFile: + name: str + data: bytes + mime: str + + +def title_of(session: DebugSession) -> str: + # Not a sentence of ours: the composer always saves a title in the user's language, and a + # session exported before it ever saved is named by its date alone, in no language at all. + title = ((session.draft or {}).get("title") or "").strip() + return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}" + + +def body_of(session: DebugSession) -> str: + return ((session.draft or {}).get("body") or "").strip() + + +def _lines(rows) -> bytes: + return "".join( + json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows + ).encode() + + +def files(db: Session, session: DebugSession) -> list[IssueFile]: + """The session's issue as files: the text, the two logs, the screenshots.""" + rows, _ = debugsession.bundle(db, session, drop_excluded=True) + text = f"# {title_of(session)}\n\n{body_of(session)}\n" + out = [ + IssueFile("issue.md", text.encode(), "text/markdown"), + IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"), + IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"), + ] + shots = db.scalars( + select(DebugScreenshot) + .where(DebugScreenshot.session_id == session.id) + .order_by(DebugScreenshot.id) + ) + for n, shot in enumerate(shots, start=1): + out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime)) + return out + + +def zipped(items: list[IssueFile]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for item in items: + z.writestr(item.name, item.data) + return buf.getvalue() diff --git a/backend/app/models.py b/backend/app/models.py index a827d2d2..3700f58a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -619,6 +619,8 @@ class DebugSession(Base): # 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)) + # The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape). + draft: Mapped[dict | None] = mapped_column(JSONB) class DebugScreenshot(Base): diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py index a3722d74..06f8834a 100644 --- a/backend/app/routes/debug_sessions.py +++ b/backend/app/routes/debug_sessions.py @@ -10,10 +10,12 @@ form — one image per request, capped while it arrives, and kept only if its by those types, whatever the Content-Type claimed. """ +import json from datetime import datetime, timezone +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -21,6 +23,7 @@ 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.issues import bundle from app.models import DebugScreenshot, DebugSession, User from app.routes._common import capped_body, not_found, owned_or_404 from app.utils import iso @@ -111,6 +114,59 @@ def session_events( return {"items": [logsink.serialize(r) for r in rows], "total": total} +# --- the draft and its export --------------------------------------------------------------- +# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it +# was — bounded here so a draft cannot become a place to store anything else. +FORM_MAX_BYTES = 64 * 1024 + + +class DraftIn(BaseModel): + title: str = Field(max_length=200) + # The rendered markdown the export and the issue carry. + body: str = Field(max_length=50_000) + # Log rows taken out of the evidence in the review. + excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX) + form: dict[str, Any] = Field(default_factory=dict) + + +@router.put("/{sid}/draft") +def save_draft( + sid: str, + body: DraftIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + if session.issue_url is not None: + # Sent: the issue is on the tracker now, and a draft edited after it would say otherwise. + raise HTTPException(status_code=409, detail={"code": "already_sent"}) + if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES: + raise HTTPException(status_code=413, detail={"code": "too_large"}) + debugsession.save_draft(session, body.model_dump()) + db.commit() + return {"ok": True, "session": _view(db, session)} + + +@router.get("/{sid}/export") +def export_session( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> Response: + """The issue as a zip — the text, the two logs and the screenshots — for an instance without an + issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is + what it saved.""" + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + data = bundle.zipped(bundle.files(db, session)) + name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip" + return Response( + content=data, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{name}"', + "Cache-Control": "no-store", + }, + ) + + # --- screenshots --------------------------------------------------------------------------- def _shot_view(shot: DebugScreenshot) -> dict: return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)} diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py index f279d489..2ca2b60a 100644 --- a/backend/tests/test_debug_sessions.py +++ b/backend/tests/test_debug_sessions.py @@ -127,10 +127,13 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user): ("get", f"/api/debug-sessions/{sid}/screenshots"), ("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"), ("delete", f"/api/debug-sessions/{sid}/screenshots/{shot}"), + ("get", f"/api/debug-sessions/{sid}/export"), ): 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.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}) + assert r.status_code == 404 r = stranger.post( f"/api/debug-sessions/{sid}/screenshots", content=_PNG, @@ -446,3 +449,97 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording): 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} + + +# --- the draft and the export --------------------------------------------------------------- + + +def test_a_draft_is_kept_and_comes_back_with_the_session(recording): + client, sid = recording + draft = { + "title": "Spinner stays", + "body": "## Steps\n1. open", + "excluded": [3], + "form": {"a": 1}, + } + r = client.put(f"/api/debug-sessions/{sid}/draft", json=draft) + assert r.status_code == 200 and r.json()["session"]["draft"] == draft + assert client.get(f"/api/debug-sessions/{sid}").json()["draft"] == draft + + +def test_a_draft_is_bounded(recording, monkeypatch): + from app.routes import debug_sessions as routes + + client, sid = recording + monkeypatch.setattr(routes, "FORM_MAX_BYTES", 16) + r = client.put( + f"/api/debug-sessions/{sid}/draft", + json={"title": "t", "body": "b", "form": {"text": "x" * 32}}, + ) + assert r.status_code == 413 + too_long = {"title": "t" * 201, "body": "b"} + assert client.put(f"/api/debug-sessions/{sid}/draft", json=too_long).status_code == 422 + + +def test_a_sent_session_keeps_its_draft(db, recording): + client, sid = recording + db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1" + db.commit() + r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"}) + assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent" + + +def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user): + import io + import json + import zipfile + + user = make_user() + client = as_user(user) + sid = _start(client)["id"] + now = datetime.now(timezone.utc) + + def row(ts, source, msg): + return LogEvent( + ts=ts, + level="INFO", + sev=9, + source=source, + logger=source, + event="x.y", + msg=msg, + user_id=user.id, + debug_session_id=sid, + ) + + kept_web, dropped_web, kept_api = ( + row(now, "web", "kept click"), + row(now + timedelta(seconds=1), "web", "private search"), + row(now + timedelta(seconds=2), "api", "GET /api/x 200"), + ) + db.add_all([kept_web, dropped_web, kept_api]) + db.commit() + _upload(client, sid, _PNG) + _upload(client, sid, _JPEG) + client.put( + f"/api/debug-sessions/{sid}/draft", + json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]}, + ) + + r = client.get(f"/api/debug-sessions/{sid}/export") + assert r.status_code == 200 and r.headers["content-type"] == "application/zip" + assert "attachment;" in r.headers["content-disposition"] + z = zipfile.ZipFile(io.BytesIO(r.content)) + assert z.namelist() == [ + "issue.md", + "client.log", + "server.log", + "screenshot-1.png", + "screenshot-2.jpg", + ] + assert z.read("issue.md").decode() == "# Spinner stays\n\nIt spins.\n" + web = [json.loads(line)["msg"] for line in z.read("client.log").decode().splitlines()] + api = [json.loads(line)["msg"] for line in z.read("server.log").decode().splitlines()] + assert "kept click" in web and "private search" not in web + assert "GET /api/x 200" in api and all(m != "kept click" for m in api) + assert z.read("screenshot-1.png") == _PNG diff --git a/frontend/src/components/DebugRecorder.dom.test.tsx b/frontend/src/components/DebugRecorder.dom.test.tsx index 0a6eb327..b6130bca 100644 --- a/frontend/src/components/DebugRecorder.dom.test.tsx +++ b/frontend/src/components/DebugRecorder.dom.test.tsx @@ -35,6 +35,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => ended_reason: null, issue_url: null, screenshots: 0, + draft: null, ...over, }); const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") => diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx index a72f2b8d..31073588 100644 --- a/frontend/src/components/DebugRecorder.tsx +++ b/frontend/src/components/DebugRecorder.tsx @@ -1,14 +1,15 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Camera, Flag, Square } from "lucide-react"; -import type { DebugSessionLevel } from "../lib/api"; +import { api, type DebugSessionLevel } from "../lib/api"; import { canCaptureTab } from "../lib/captureTab"; import { closeRecorderStart, openComposer, openRecorderStart, + useComposerSession, useRecorderStartOpen, useStepCount, } from "../lib/debugRecorder"; @@ -19,8 +20,10 @@ import { dockUnderToasts } from "../lib/toastDock"; import { useMe } from "../lib/useMe"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import SegmentedToggle from "./ui/SegmentedToggle"; import { inputCls } from "./ui/form"; +import IssueComposer from "./IssueComposer"; import { useDebugRecording } from "./useDebugRecording"; // The debug recorder (E16): Start → reproduce → Stop. @@ -47,6 +50,7 @@ export default function DebugRecorder() { const me = useMe(); const { session } = useDebugRecording(); const startOpen = useRecorderStartOpen(); + const composing = useComposerSession(); const id = session?.id; useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]); @@ -66,7 +70,12 @@ export default function DebugRecorder() { return () => window.clearTimeout(timer); }, [id, until, minutes, qc, t]); - return startOpen ? : null; + return ( + <> + {startOpen && } + {composing && } + + ); } function StartDialog() { @@ -216,8 +225,17 @@ function Elapsed({ since }: { since: string }) { ); } -/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */ +/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */ export function RecorderSettings() { + return ( +
+ + +
+ ); +} + +function RecorderControl() { const { t } = useTranslation(); const { session, stop } = useDebugRecording(); if (!session) @@ -294,3 +312,53 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) ); } + +/** 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} + /> +
+
+ +