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:
@@ -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")
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`."""
|
||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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") =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user