feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)

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
This commit is contained in:
2026-09-19 22:26:49 +02:00
parent 9f177557d2
commit 8d0083d4e4
23 changed files with 1620 additions and 35 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ how it gets built. Concretely:
## Architecture shape (don't fight it) ## Architecture shape (don't fight it)
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `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`, `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.** `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 Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
@@ -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")
+3 -2
View File
@@ -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. 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. 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 **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: 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 `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`, 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
+19 -10
View File
@@ -127,6 +127,13 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime |
return session.level, session.until 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: def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
if session is None: if session is None:
return None return None
@@ -139,6 +146,7 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_reason": session.ended_reason, "ended_reason": session.ended_reason,
"issue_url": session.issue_url, "issue_url": session.issue_url,
"screenshots": screenshots, "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))) 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 """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( rows = list(
db.scalars( db.scalars(
select(LogEvent) select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
.where(*_evidence(session))
.order_by(LogEvent.ts, LogEvent.id)
.limit(BUNDLE_MAX)
) )
) )
total = len(rows) total = len(rows)
if total == BUNDLE_MAX: if total == BUNDLE_MAX:
total = ( total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
db.scalar(select(func.count()).select_from(LogEvent).where(*_evidence(session)))
or total
)
return rows, total return rows, total
+3
View File
@@ -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`."""
+77
View File
@@ -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()
+2
View File
@@ -619,6 +619,8 @@ class DebugSession(Base):
# app/debugsession.py `EndReason`: stopped by the user, or past `until`. # app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16)) ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512)) 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): class DebugScreenshot(Base):
+57 -1
View File
@@ -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. those types, whatever the Content-Type claimed.
""" """
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,6 +23,7 @@ from sqlalchemy.orm import Session
from app import debugsession, logs, logsink from app import debugsession, logs, logsink
from app.auth import require_human from app.auth import require_human
from app.db import get_db from app.db import get_db
from app.issues import bundle
from app.models import DebugScreenshot, DebugSession, User from app.models import DebugScreenshot, DebugSession, User
from app.routes._common import capped_body, not_found, owned_or_404 from app.routes._common import capped_body, not_found, owned_or_404
from app.utils import iso from app.utils import iso
@@ -111,6 +114,59 @@ def session_events(
return {"items": [logsink.serialize(r) for r in rows], "total": total} 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 --------------------------------------------------------------------------- # --- screenshots ---------------------------------------------------------------------------
def _shot_view(shot: DebugScreenshot) -> dict: def _shot_view(shot: DebugScreenshot) -> dict:
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)} return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
+97
View File
@@ -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"),
("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"), ("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
("delete", 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) r = getattr(stranger, method)(path)
assert r.status_code == 404, (method, path, r.status_code) assert r.status_code == 404, (method, path, r.status_code)
assert r.json()["detail"] == {"code": "not_found", "reason": "debug_session"} 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( r = stranger.post(
f"/api/debug-sessions/{sid}/screenshots", f"/api/debug-sessions/{sid}/screenshots",
content=_PNG, 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(): 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} 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
@@ -35,6 +35,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
ended_reason: null, ended_reason: null,
issue_url: null, issue_url: null,
screenshots: 0, screenshots: 0,
draft: null,
...over, ...over,
}); });
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") => const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
+72 -4
View File
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next"; 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 { 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 { canCaptureTab } from "../lib/captureTab";
import { import {
closeRecorderStart, closeRecorderStart,
openComposer, openComposer,
openRecorderStart, openRecorderStart,
useComposerSession,
useRecorderStartOpen, useRecorderStartOpen,
useStepCount, useStepCount,
} from "../lib/debugRecorder"; } from "../lib/debugRecorder";
@@ -19,8 +20,10 @@ import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording"; import { useDebugRecording } from "./useDebugRecording";
// The debug recorder (E16): Start → reproduce → Stop. // The debug recorder (E16): Start → reproduce → Stop.
@@ -47,6 +50,7 @@ export default function DebugRecorder() {
const me = useMe(); const me = useMe();
const { session } = useDebugRecording(); const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen(); const startOpen = useRecorderStartOpen();
const composing = useComposerSession();
const id = session?.id; const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]); useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
@@ -66,7 +70,12 @@ export default function DebugRecorder() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]); }, [id, until, minutes, qc, t]);
return startOpen ? <StartDialog /> : null; return (
<>
{startOpen && <StartDialog />}
{composing && <IssueComposer id={composing} />}
</>
);
} }
function StartDialog() { 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() { export function RecorderSettings() {
return (
<div className="flex flex-col gap-4">
<RecorderControl />
<SessionHistory />
</div>
);
}
function RecorderControl() {
const { t } = useTranslation(); const { t } = useTranslation();
const { session, stop } = useDebugRecording(); const { session, stop } = useDebugRecording();
if (!session) if (!session)
@@ -294,3 +312,53 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
</div> </div>
); );
} }
/** 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 (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.history.failed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
return (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
{items.length === 0 ? (
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
) : (
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
{items.map((s) => (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
<span className={`chip px-2 py-0.5 text-2xs ${s.issue_url ? "text-success" : ""}`}>
{t(
s.issue_url
? "recorder.history.sent"
: s.draft
? "recorder.history.draft"
: "recorder.history.unwritten",
)}
</span>
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
{t("recorder.history.open")}
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
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<typeof import("../lib/api")>();
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>): 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> = {}): 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(
<QueryClientProvider client={qc}>
<IssueComposer id={SID} />
</QueryClientProvider>,
);
}
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();
});
});
+565
View File
@@ -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 (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
{failed && !(sessionQ.data && eventsQ.data) ? (
<StateMessage
tone="error"
onRetry={() => {
void sessionQ.refetch();
void eventsQ.refetch();
}}
>
{t("recorder.composer.loadFailed")}
</StateMessage>
) : !sessionQ.data || !eventsQ.data ? (
<LoadingState />
) : (
<ComposerBody
session={sessionQ.data}
rows={eventsQ.data.items}
total={eventsQ.data.total}
/>
)}
</Modal>
);
}
/** 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<DraftForm> | 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<DraftForm>(
() =>
savedForm(session) ?? {
title: "",
description: "",
steps: draftSteps(rows, pageName, logClock),
excluded: [],
},
);
const [reviewing, setReviewing] = useState<Source | null>(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<string, unknown>,
}),
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 (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: started.toLocaleString(i18n.language),
minutes,
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
{sent && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
<span>{t("recorder.composer.sent")}</span>
<a
href={session.issue_url!}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline"
>
{t("recorder.composer.openIssue")}
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
</a>
</div>
)}
{reviewing ? (
<ReviewList
rows={rows.filter((r) => 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)}
/>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<DraftFields form={form} edit={edit} env={env} readOnly={sent} />
<Attachments
session={session}
shots={shots}
counts={{
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
}}
readOnly={sent}
onReview={setReviewing}
/>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<span className="mr-auto text-xs text-muted" aria-live="polite">
{save.isPending
? t("recorder.composer.saving")
: save.isSuccess
? t("recorder.composer.saved")
: ""}
</span>
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
</Button>
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
{t("recorder.composer.export")}
</Button>
</div>
</div>
);
}
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<DraftStep>) =>
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
return (
<div className="flex flex-col gap-4 min-w-0">
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-title" className="text-sm text-muted">
{t("recorder.composer.fieldTitle")}
</label>
<input
id="issue-title"
value={form.title}
readOnly={readOnly}
maxLength={200}
placeholder={t("recorder.composer.titlePlaceholder")}
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-description" className="text-sm text-muted">
{t("recorder.composer.description")}
</label>
<textarea
id="issue-description"
value={form.description}
readOnly={readOnly}
rows={4}
placeholder={t("recorder.composer.descriptionPlaceholder")}
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
className={`${inputCls} resize-y`}
/>
</div>
<fieldset className="flex flex-col gap-1.5 min-w-0">
<legend className="mb-1.5 text-sm text-muted">{t("recorder.composer.steps")}</legend>
<ol className="flex flex-col gap-1.5">
{form.steps.map((step, i) => (
<li
key={step.id}
className="flex items-center gap-2 rounded-lg bg-surface/60 pl-2.5 pr-1 py-1"
>
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
{i + 1}.
</span>
<input
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
value={step.text}
readOnly={readOnly}
placeholder={t("recorder.composer.stepPlaceholder")}
onChange={(e) => setStep(i, { text: e.target.value })}
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
/>
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
{t(`recorder.composer.stepKind.${step.kind}`)}
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
</span>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeStep", { n: i + 1 })}
onClick={() => edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }))}
/>
)}
</li>
))}
</ol>
{!readOnly && (
<Button
variant="link"
className="self-start"
icon={<Plus className="w-4 h-4" />}
onClick={() =>
edit((f) => ({
...f,
steps: [...f.steps, { id: `m${Date.now()}`, kind: "manual", text: "" }],
}))
}
>
{t("recorder.composer.addStep")}
</Button>
)}
</fieldset>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.composer.environment")}</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm">
{env.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{t(`recorder.composer.env.${k}`)}</dt>
<dd className="break-words tabular-nums">{v}</dd>
</div>
))}
</dl>
</div>
</div>
);
}
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
function Attachments({
session,
shots,
counts,
readOnly,
onReview,
}: {
session: DebugSession;
shots: DebugScreenshot[];
counts: Record<Source, [kept: number, total: number]>;
readOnly: boolean;
onReview: (source: Source) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(session.id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id) });
};
const add = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const addFiles = (files: Iterable<File>) => {
for (const file of files) if (IMAGE_TYPES.includes(file.type)) add.mutate(file);
};
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...e.clipboardData.files].filter((f) => IMAGE_TYPES.includes(f.type));
if (!images.length) return;
e.preventDefault();
for (const image of images) add.mutate(image);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
};
const logRow = (source: Source, icon: React.ReactNode) => {
const [kept, total] = counts[source];
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
{icon}
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">
{t(source === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog")}
</span>
<span className="text-xs text-muted tabular-nums">
{kept === total
? t("recorder.composer.lines", { count: total })
: t("recorder.composer.linesKept", { kept, total })}
{` · ${source === "web" ? "client.log" : "server.log"}`}
</span>
</div>
{total > 0 && (
<Button variant="outline" size="sm" onClick={() => onReview(source)}>
{t("recorder.composer.review")}
</Button>
)}
</div>
);
};
return (
<div
className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"
onPaste={readOnly ? undefined : onPaste}
>
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{shots.map((shot, i) => (
<div
key={shot.id}
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
>
<img
src={api.debugScreenshotUrl(session.id, shot.id)}
alt={t("recorder.composer.screenshot", { n: i + 1 })}
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">
{Math.round(shot.size / 1024)} KB
</span>
</div>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeShot", { n: i + 1 })}
loading={remove.isPending && remove.variables === shot.id}
onClick={() => remove.mutate(shot.id)}
/>
)}
</div>
))}
{!readOnly && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
>
{t("recorder.composer.drop")}{" "}
<label className="cursor-pointer font-semibold text-accent hover:underline">
{t("recorder.composer.pick")}
<input
type="file"
accept={IMAGE_TYPES.join(",")}
multiple
className="sr-only"
onChange={(e) => {
addFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
</label>
</div>
)}
</div>
);
}
/** One log, line by line: an unticked line is not sent. */
function ReviewList({
rows,
name,
excluded,
readOnly,
onToggle,
onDone,
}: {
rows: ServerLogRow[];
name: string;
excluded: Set<number>;
readOnly: boolean;
onToggle: (id: number) => void;
onDone: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
</div>
<Button variant="primary" size="sm" onClick={onDone}>
{t("recorder.composer.reviewDone")}
</Button>
</div>
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
{rows.map((row) => {
const tag = LEVEL_TAG[row.level];
const off = excluded.has(row.id);
return (
<li key={row.id} className="border-b border-border/50 last:border-b-0">
<label
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
off ? "opacity-50 line-through" : ""
}`}
>
<input
type="checkbox"
checked={!off}
disabled={readOnly}
aria-label={t("recorder.composer.keepRow", {
event: row.event ?? "",
msg: row.msg,
})}
onChange={() => onToggle(row.id)}
/>
<span className="tabular-nums text-muted">{logClock(row.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{row.event}</span>
<span className="col-span-4 truncate sm:col-span-1">{row.msg}</span>
</label>
</li>
);
})}
</ul>
</div>
);
}
+1 -16
View File
@@ -2,28 +2,13 @@ import type { ReactNode } from "react";
import type { Level } from "../lib/log"; import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { LEVEL_TAG, logClock } from "./logLineParts";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin // One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens // diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both. // in place to whatever detail its screen has. One component, so a level reads the same on both.
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does. // Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
export default function LogLine({ export default function LogLine({
ts, ts,
level, level,
+21
View File
@@ -0,0 +1,21 @@
import type { Level } from "../lib/log";
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
// LogLine.tsx because a component file exports only components (react-refresh).
export const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
export function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder
import { flushNow, liveSession } from "../lib/diagnostics"; import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log"; import { log } from "../lib/log";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { updateMeCache, useMe } from "../lib/useMe"; import { updateMeCache, useMe } from "../lib/useMe";
/** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings → /** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings →
@@ -18,8 +19,10 @@ export function useDebugRecording() {
const qc = useQueryClient(); const qc = useQueryClient();
const me = useMe(); const me = useMe();
const session = liveSession(me?.debug_session); const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) => const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next })); updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions() });
};
const start = useMutation({ const start = useMutation({
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level), mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
@@ -43,5 +43,77 @@
"start": "Start a debug recording", "start": "Start a debug recording",
"step": "Mark a step in the recording", "step": "Mark a step in the recording",
"stop": "Stop the debug recording" "stop": "Stop the debug recording"
},
"composer": {
"title": "Bug report",
"meta": "{{start}} · {{minutes}} min · {{level}}",
"fieldTitle": "Title",
"titlePlaceholder": "What went wrong?",
"description": "Description",
"descriptionPlaceholder": "What did you expect, and what happened instead?",
"steps": "Steps — from your markers and page changes, editable",
"stepKind": {
"marker": "marker",
"nav": "page",
"manual": "added"
},
"stepLabel": "Step {{n}}",
"stepPlaceholder": "What did you do?",
"withShot": "image",
"addStep": "Add a step",
"removeStep": "Remove step {{n}}",
"environment": "Environment — automatic",
"attachments": "Attachments — what is sent, each one removable",
"clientLog": "Browser log",
"serverLog": "Server log",
"lines_one": "{{count}} line",
"lines_other": "{{count}} lines",
"linesKept": "{{kept}} of {{total}} lines",
"review": "Review",
"reviewTitle": "{{name}} — review",
"reviewHint": "An unticked line is not sent.",
"reviewDone": "Done",
"keepRow": "Keep line: {{event}} {{msg}}",
"screenshot": "Screenshot {{n}}",
"removeShot": "Remove screenshot {{n}}",
"drop": "Paste an image with Ctrl+V, drop it here, or",
"pick": "choose a file",
"close": "Close",
"export": "Download as zip",
"saving": "Saving…",
"saved": "Draft saved",
"saveFailed": "Couldn't save the draft",
"loadFailed": "Couldn't load the session",
"truncated": "The logs carry the first {{kept}} of {{total}} lines.",
"sent": "Sent as an issue.",
"openIssue": "Open the issue",
"env": {
"version": "Version",
"browser": "Browser",
"os": "System",
"window": "Window",
"language": "Language"
}
},
"issue": {
"steps": "Steps",
"environment": "Environment",
"screenshot": "screenshot",
"attachments": "Attachments",
"clientLog_one": "{{count}} line from the browser",
"clientLog_other": "{{count}} lines from the browser",
"serverLog_one": "{{count}} line from the server",
"serverLog_other": "{{count}} lines from the server",
"screenshots_one": "{{count}} screenshot",
"screenshots_other": "{{count}} screenshots"
},
"history": {
"title": "Past sessions",
"empty": "No session recorded yet.",
"open": "Open",
"sent": "Sent",
"draft": "Draft",
"unwritten": "Not written yet",
"failed": "Couldn't load the sessions"
} }
} }
@@ -43,5 +43,77 @@
"start": "Hibakereső rögzítés indítása", "start": "Hibakereső rögzítés indítása",
"step": "Lépés megjelölése a rögzítésben", "step": "Lépés megjelölése a rögzítésben",
"stop": "Hibakereső rögzítés leállítása" "stop": "Hibakereső rögzítés leállítása"
},
"composer": {
"title": "Hibajegy",
"meta": "{{start}} · {{minutes}} perc · {{level}}",
"fieldTitle": "Cím",
"titlePlaceholder": "Mi romlott el?",
"description": "Leírás",
"descriptionPlaceholder": "Mit vártál, és mi történt helyette?",
"steps": "Lépések — a jelölőidből és az oldalváltásokból, szerkeszthető",
"stepKind": {
"marker": "jelölő",
"nav": "oldalváltás",
"manual": "saját"
},
"stepLabel": "{{n}}. lépés",
"stepPlaceholder": "Mit csináltál?",
"withShot": "kép",
"addStep": "Lépés hozzáadása",
"removeStep": "{{n}}. lépés törlése",
"environment": "Környezet — automatikus",
"attachments": "Csatolmányok — ami elmegy, egyenként kivehető",
"clientLog": "Böngészőnapló",
"serverLog": "Szervernapló",
"lines_one": "{{count}} sor",
"lines_other": "{{count}} sor",
"linesKept": "{{kept}} / {{total}} sor",
"review": "Átnézés",
"reviewTitle": "{{name}} — átnézés",
"reviewHint": "Pipa nélküli sor nem megy el.",
"reviewDone": "Kész",
"keepRow": "Sor megtartása: {{event}} {{msg}}",
"screenshot": "{{n}}. képernyőkép",
"removeShot": "{{n}}. képernyőkép kivétele",
"drop": "Kép beillesztése: Ctrl+V, vagy húzd ide, vagy",
"pick": "válassz fájlt",
"close": "Bezárás",
"export": "Letöltés zipként",
"saving": "Mentés…",
"saved": "Vázlat mentve",
"saveFailed": "Nem sikerült menteni a vázlatot",
"loadFailed": "Nem sikerült betölteni a munkamenetet",
"truncated": "A napló az első {{kept}} sort tartalmazza, összesen {{total}} volt.",
"sent": "Elküldve hibajegyként.",
"openIssue": "Hibajegy megnyitása",
"env": {
"version": "Verzió",
"browser": "Böngésző",
"os": "Rendszer",
"window": "Ablak",
"language": "Nyelv"
}
},
"issue": {
"steps": "Lépések",
"environment": "Környezet",
"screenshot": "képernyőkép",
"attachments": "Csatolmányok",
"clientLog_one": "{{count}} sor a böngészőből",
"clientLog_other": "{{count}} sor a böngészőből",
"serverLog_one": "{{count}} sor a szerverről",
"serverLog_other": "{{count}} sor a szerverről",
"screenshots_one": "{{count}} képernyőkép",
"screenshots_other": "{{count}} képernyőkép"
},
"history": {
"title": "Korábbi munkamenetek",
"empty": "Még nincs rögzített munkamenet.",
"open": "Megnyitás",
"sent": "Elküldve",
"draft": "Vázlat",
"unwritten": "Megírásra vár",
"failed": "Nem sikerült betölteni a munkameneteket"
} }
} }
+20
View File
@@ -54,6 +54,17 @@ export interface DebugSession {
/** The issue it was sent as, once sent. */ /** The issue it was sent as, once sent. */
issue_url: string | null; issue_url: string | null;
screenshots: number; screenshots: number;
/** The issue being written from it (the composer saves as it goes); null until it first does. */
draft: DebugDraft | null;
}
/** A saved draft: the rendered text the export and the issue carry, plus the composer's own fields
* (`form`, lib/issueDraft `DraftForm`) to reopen it as it was. */
export interface DebugDraft {
title: string;
body: string;
excluded: number[];
form: Record<string, unknown>;
} }
export interface DebugScreenshot { export interface DebugScreenshot {
@@ -87,6 +98,15 @@ export const debugSessionsApi = {
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() }, headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
body: image, body: image,
}), }),
saveDebugDraft: (id: string, draft: DebugDraft): Promise<{ ok: true; session: DebugSession }> =>
req(`${SESSIONS}/${id}/draft`, { method: "PUT", body: JSON.stringify(draft) }),
/** The issue as a zip, built from the SAVED draft. A navigation, not a `req` — so the account
* rides as `?account=`, as for every download. */
debugExportUrl: (id: string): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/export`;
return a != null ? `${url}?account=${a}` : url;
},
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> => removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }), req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the /** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
@@ -38,6 +38,7 @@ const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
ended_reason: null, ended_reason: null,
issue_url: null, issue_url: null,
screenshots: 0, screenshots: 0,
draft: null,
...over, ...over,
}); });
+128
View File
@@ -0,0 +1,128 @@
// E16 S3: the draft a session suggests, and the markdown an issue carries. Pure, so the composer's
// preview and what it saves are provably one computation.
import { describe, expect, it } from "vitest";
import type { ServerLogRow } from "./api";
import { draftSteps, osName, renderIssue, type DraftForm, type IssueText } from "./issueDraft";
let nextId = 1;
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
id: nextId++,
ts: "2026-09-19T14:05:31.000Z",
level: "INFO",
source: "web",
logger: "web",
event: "x.y",
msg: "",
attrs: null,
exc: null,
request_id: null,
user_id: 1,
job_id: null,
debug_session_id: "0123456789abcdef",
version: null,
...over,
});
const nav = (page: string) => row({ event: "nav.view", attrs: { via: "push", page } });
const step = (label?: string) =>
row({ event: "debug.step", attrs: label ? { n: 1, label } : { n: 1 } });
const pageName = (p: string) => p.toUpperCase();
const clock = () => "14:05:31";
describe("the steps a session suggests", () => {
it("are its markers and the page changes between them, in order", () => {
const steps = draftSteps(
[nav("plex"), nav("plex"), step("opened the player"), nav("settings")],
pageName,
clock,
);
expect(steps.map((s) => [s.kind, s.text])).toEqual([
["nav", "PLEX"], // the same page twice in a row is one step
["marker", "opened the player"],
["nav", "SETTINGS"],
]);
});
it("name a marker set without a label by its time, so the issue keeps it", () => {
expect(draftSteps([step()], pageName, clock)[0]?.text).toBe("14:05:31");
});
it("hang a screenshot on the step before it, and leave server rows out", () => {
const steps = draftSteps(
[
step("spinner"),
row({ event: "debug.screenshot" }),
row({ source: "api", event: "nav.view" }),
],
pageName,
clock,
);
expect(steps).toHaveLength(1);
expect(steps[0]?.shot).toBe(true);
});
});
const TEXT: IssueText = {
steps: "Steps",
environment: "Environment",
envKeys: { version: "Version" },
screenshot: "screenshot",
attachments: "Attachments",
clientLog: (n) => `${n} browser lines`,
serverLog: (n) => `${n} server lines`,
screenshots: (n) => `${n} screenshots`,
};
const form = (over: Partial<DraftForm> = {}): DraftForm => ({
title: "t",
description: "It spins.",
steps: [
{ id: "a", kind: "nav", text: "Plex" },
{ id: "b", kind: "marker", text: " ", shot: false },
{ id: "c", kind: "marker", text: "Resume", shot: true },
],
excluded: [],
...over,
});
describe("the issue text", () => {
it("carries the description, the non-empty steps, the environment and the attachments", () => {
const md = renderIssue(
form(),
[["version", "0.93.0 | beta"]],
{ client: 3, server: 0, screenshots: 1 },
TEXT,
);
expect(md).toBe(
[
"It spins.",
"### Steps\n\n1. Plex\n2. Resume _(screenshot)_",
"### Environment\n\n| | |\n|---|---|\n| Version | 0.93.0 \\| beta |",
"### Attachments\n\n- `client.log` — 3 browser lines\n- 1 screenshots",
].join("\n\n"),
);
});
it("says when the logs were cut", () => {
const md = renderIssue(
form(),
[],
{ client: 1, server: 1, screenshots: 0 },
{
...TEXT,
truncated: "cut at 5000",
},
);
expect(md.endsWith("_cut at 5000_")).toBe(true);
});
});
describe("the system a browser runs on", () => {
it.each([
["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/152.0.0.0 Mobile", "Android 10"],
["Mozilla/5.0 (iPad; CPU OS 26_6_1 like Mac OS X) AppleWebKit/605.1.15", "iPad 26.6.1"],
["Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)", "iPhone 18.2"],
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0", "Windows NT 10.0"],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15", "macOS 10.15.7"],
["Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0", "Linux"],
])("%s → %s", (ua, os) => expect(osName(ua)).toBe(os));
});
+147
View File
@@ -0,0 +1,147 @@
// The issue drafted from a debug session (E16 S3) — pure functions, so what the composer shows and
// what it saves are one computation, testable without a browser.
//
// The composer renders the markdown HERE, in the user's language, and the server carries it as it
// was reviewed (backend app/issues/bundle.py): the server adds no sentence of its own.
import type { ServerLogRow } from "./api";
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
/** Where a step came from: a marker the user set, a page change, or typed in the composer. */
type StepKind = "marker" | "nav" | "manual";
export interface DraftStep {
id: string;
kind: StepKind;
text: string;
/** A screenshot was taken at this step. */
shot?: boolean;
}
/** What the composer edits and saves (`form` in the saved draft). */
export interface DraftForm {
title: string;
description: string;
steps: DraftStep[];
/** Log row ids taken out of the evidence. */
excluded: number[];
}
/** The steps a session suggests: every marker the user set, and the page changes between them. A
* page change repeated back to back is one step; a screenshot belongs to the step before it. A
* marker set without a label is named by its time (`clock`) — the one thing that finds it in the
* log — rather than left empty, where the issue would drop it. */
export function draftSteps(
rows: readonly ServerLogRow[],
pageName: (page: string) => string,
clock: (ts: string) => string,
) {
const steps: DraftStep[] = [];
for (const row of rows) {
if (row.source !== "web") continue;
const attrs = row.attrs ?? {};
if (row.event === "debug.step") {
const label = typeof attrs.label === "string" ? attrs.label.trim() : "";
steps.push({ id: `r${row.id}`, kind: "marker", text: label || clock(row.ts) });
} else if (row.event === "nav.view") {
const page = typeof attrs.page === "string" ? attrs.page : String(attrs.path ?? "");
const text = page ? pageName(page) : "";
const last = steps[steps.length - 1];
if (!text || (last?.kind === "nav" && last.text === text)) continue;
steps.push({ id: `r${row.id}`, kind: "nav", text });
} else if (row.event === "debug.screenshot") {
const last = steps[steps.length - 1];
if (last) last.shot = true;
}
}
return steps;
}
/** The operating system a user agent names. Checked most specific first: Android's agent also says
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */
export function osName(ua: string): string {
const tests: [RegExp, (m: RegExpExecArray) => string][] = [
[/Android [\d.]+/, (m) => m[0]],
[/(iPhone|iPad)[^)]*? OS ([\d_]+)/, (m) => `${m[1]} ${m[2]!.replace(/_/g, ".")}`],
[/Windows NT [\d.]+/, (m) => m[0]],
[/Mac OS X ([\d_]+)/, (m) => `macOS ${m[1]!.replace(/_/g, ".")}`],
[/CrOS|Linux/, (m) => m[0]],
];
for (const [re, name] of tests) {
const m = re.exec(ua);
if (m) return name(m);
}
return "?";
}
/** The environment table: what a developer asks first. Read from this browser at composing time —
* the same browser the session recorded in, or the report says which one it came from. */
export function environment(language: string): [key: string, value: string][] {
const ua = navigator.userAgent;
const browser =
/Edg\/([\d.]+)/.exec(ua)?.[0] ??
/Firefox\/([\d.]+)/.exec(ua)?.[0] ??
/Chrome\/([\d.]+)/.exec(ua)?.[0] ??
/Version\/([\d.]+).*Safari/.exec(ua)?.[0] ??
ua;
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return [
["version", `${FRONTEND_VERSION} (${FRONTEND_SHA})`],
["browser", browser.replace("/", " ")],
["os", osName(ua)],
[
"window",
`${window.innerWidth} × ${window.innerHeight} · ${window.devicePixelRatio}x · ${Math.round((rootPx / 16) * 100)}%`,
],
["language", language],
];
}
/** A table cell: a pipe or a line break would break the markdown table. */
const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
export interface IssueText {
steps: string;
environment: string;
envKeys: Record<string, string>;
screenshot: string;
attachments: string;
clientLog: (n: number) => string;
serverLog: (n: number) => string;
screenshots: (n: number) => string;
/** Shown when the evidence was cut at the server's cap. */
truncated?: string;
}
/** The issue body as markdown: the description, the steps, the environment, the attachments. */
export function renderIssue(
form: DraftForm,
env: readonly [string, string][],
counts: { client: number; server: number; screenshots: number },
text: IssueText,
): string {
const parts: string[] = [];
const description = form.description.trim();
if (description) parts.push(description);
const steps = form.steps.filter((s) => s.text.trim());
if (steps.length)
parts.push(
`### ${text.steps}\n\n` +
steps
.map((s, i) => `${i + 1}. ${s.text.trim()}${s.shot ? ` _(${text.screenshot})_` : ""}`)
.join("\n"),
);
parts.push(
`### ${text.environment}\n\n| | |\n|---|---|\n` +
env.map(([k, v]) => `| ${cell(text.envKeys[k] ?? k)} | ${cell(v)} |`).join("\n"),
);
const attachments = [
counts.client ? `- \`client.log\` — ${text.clientLog(counts.client)}` : "",
counts.server ? `- \`server.log\` — ${text.serverLog(counts.server)}` : "",
counts.screenshots ? `- ${text.screenshots(counts.screenshots)}` : "",
].filter(Boolean);
if (attachments.length) {
const cut = text.truncated ? `\n\n_${text.truncated}_` : "";
parts.push(`### ${text.attachments}\n\n${attachments.join("\n")}${cut}`);
}
return parts.join("\n\n");
}
+5
View File
@@ -95,6 +95,11 @@ export const qk = {
adminLogs: (filters: object) => ["admin-logs", filters] as const, adminLogs: (filters: object) => ["admin-logs", filters] as const,
adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const, adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const, adminStorage: () => ["admin-storage"] as const,
// --- debug sessions (E16) ---
debugSessions: () => ["debug-sessions"] as const,
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
adminDownloads: () => ["admin-downloads"] as const, adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const, adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const, demoWhitelist: () => ["demo-whitelist"] as const,