release: 0.93.0 (feature/e16-debug-issue)
gate / frontend (push) Failing after 4m11s
gate / backend (push) Successful in 5m58s
gate / frontend (push) Failing after 4m11s
gate / backend (push) Successful in 5m58s
This commit is contained in:
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.93.0 — 2026-09-20
|
||||
|
||||
A way to report a fault from inside the app: record what you did, mark the steps, attach screenshots — and the app writes the report for you, with the logs of exactly that session attached.
|
||||
|
||||
### Features
|
||||
|
||||
- Start a recording (the command palette, or Settings → Diagnostics) and a pill follows you: a timer, “Step” to mark what you just did, a screenshot button, and Stop. Everything you and the server do while it runs is collected under that one session, and nothing else is.
|
||||
- Stop opens the composer: a drafted title and description, the steps you marked interleaved with the pages you visited, a table of your browser and the app's version, and the attachments — each one reviewable, each one removable.
|
||||
- The log lines that would be sent can be read before they go, line by line, and any of them left out.
|
||||
- Screenshots come from a paste, a drop, a file, or capturing the tab — up to eight, and any of them can be dropped again before sending.
|
||||
- Send opens an issue on the project's tracker with the report and its logs attached, and the preview shows it exactly as the tracker will render it. Five reports an hour.
|
||||
- Without a tracker configured — or whenever you would rather keep it — the same report downloads as a zip instead.
|
||||
- An administrator sets the tracker up in Settings → System (address, repository, token) and can test the connection there. The server refuses to send to a public repository: a report carries its reporter's log.
|
||||
|
||||
### Fixes
|
||||
|
||||
- The Plex player keeps the keyboard inside itself: Tab no longer walks out of a playing film into the page behind it, not even while its own settings panel is open.
|
||||
- A report cannot be sent twice at once, from any tab: while one send runs the others say so, and a send that fails half-way retries only what is missing — never a second issue.
|
||||
- A recording ends by itself after an hour, and both Stop and that hour return your diagnostics level to whatever it was before you started.
|
||||
|
||||
### Internal
|
||||
|
||||
- A session's logs and screenshots are purged with the session after 7 days, like every other diagnostic line, and another account's session id is ignored rather than refused.
|
||||
- Four adversarial review rounds at the epic's end: 80 findings fixed across them, every regression test proven to fail with its own fix removed, and the keyboard and focus work measured in a real browser rather than argued.
|
||||
|
||||
## 0.92.0 — 2026-09-19
|
||||
|
||||
A debt burn: every item the roadmap had deferred is done or dropped — Plex playback per viewer, refusals said in your language, controls that keep the keyboard focus, and a screen reader that hears Hungarian.
|
||||
|
||||
@@ -56,9 +56,9 @@ how it gets built. Concretely:
|
||||
## Architecture shape (don't fight it)
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||
`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add debug_sessions + debug_screenshots, and index log_events.debug_session_id (E16)
|
||||
|
||||
Revision ID: 0089_debug_sessions
|
||||
Revises: 0088_channels_feed_fallback_at
|
||||
Create Date: 2026-09-19
|
||||
|
||||
A debug session is one recording: Start → reproduce → Stop (app/debugsession.py). Every log line
|
||||
of that user's requests while it runs carries its id in `log_events.debug_session_id` — the column
|
||||
E22 created and left NULL — and the issue it ends in is built from exactly those rows, so the column
|
||||
gets its index now. Partial: nearly every row has no session, and an index of NULLs would be most
|
||||
of the table for nothing.
|
||||
|
||||
At most one OPEN session per user, held by a partial unique index rather than by the start handler
|
||||
alone: two tabs pressing Start at once would otherwise both pass a "none open" check.
|
||||
|
||||
Screenshots are rows, not files: bounded (size and count per session, app/debugsession.py), gone
|
||||
with their session by cascade, and a self-host has no file volume to configure for them.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0089_debug_sessions"
|
||||
down_revision: str | None = "0088_channels_feed_fallback_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"debug_sessions",
|
||||
sa.Column("id", sa.String(16), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("level", sa.String(8), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("until", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ended_reason", sa.String(16), nullable=True),
|
||||
sa.Column("issue_url", sa.String(512), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_debug_sessions_open_per_user",
|
||||
"debug_sessions",
|
||||
["user_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("ended_at IS NULL"),
|
||||
)
|
||||
op.create_table(
|
||||
"debug_screenshots",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"session_id",
|
||||
sa.String(16),
|
||||
sa.ForeignKey("debug_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("mime", sa.String(16), nullable=False),
|
||||
sa.Column("size", sa.Integer, nullable=False),
|
||||
sa.Column("data", sa.LargeBinary, nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_log_events_debug_session",
|
||||
"log_events",
|
||||
["debug_session_id"],
|
||||
postgresql_where=sa.text("debug_session_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_log_events_debug_session", table_name="log_events")
|
||||
op.drop_table("debug_screenshots")
|
||||
op.drop_index("uq_debug_sessions_open_per_user", table_name="debug_sessions")
|
||||
op.drop_table("debug_sessions")
|
||||
@@ -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,16 +13,17 @@ Three layers, by *who* runs the code — not by feature:
|
||||
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
||||
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
||||
|
||||
2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the
|
||||
2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
|
||||
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
||||
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
||||
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
|
||||
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
|
||||
|
||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
||||
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
||||
configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log
|
||||
level), … are single-file primitives many layers import. They depend on nothing above them (a core
|
||||
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
|
||||
level) and `debugsession` (a recording that tags its user's lines, E16), … are single-file primitives many layers import. They depend on nothing above them (a core
|
||||
module must never import a `routes/…` module).
|
||||
|
||||
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
||||
|
||||
@@ -58,6 +58,9 @@ class AuditAction:
|
||||
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||
AUDIT_CLEAR = "audit_clear"
|
||||
# E16: a user sent a debug session's issue to the tracker — the one act that takes their log
|
||||
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
||||
ISSUE_CREATE = "issue_create"
|
||||
|
||||
|
||||
def record(
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@ from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app import diagnostics
|
||||
from app import debugsession
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
from app import mailtext
|
||||
@@ -367,7 +367,11 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
|
||||
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
|
||||
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
||||
# So does a debug session the request names (E16): only the caller's own open one counts, and a
|
||||
# header naming anything else is ignored rather than refused — a stale tab must keep working.
|
||||
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
||||
level, until = debugsession.level_for(user, session)
|
||||
logs.set_user(user.id, level, until, session.id if session else None)
|
||||
request.state.user_id = user.id
|
||||
return user
|
||||
|
||||
|
||||
@@ -351,6 +351,13 @@ class Settings(BaseSettings):
|
||||
tmdb_api_base: str = "https://api.themoviedb.org/3"
|
||||
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
||||
tmdb_language: str = "en-US"
|
||||
|
||||
# --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the
|
||||
# composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's
|
||||
# log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
|
||||
issues_gitea_url: str = ""
|
||||
issues_gitea_repo: str = ""
|
||||
issues_gitea_token: str = ""
|
||||
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
|
||||
tmdb_enrich_interval_min: int = 120
|
||||
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from.
|
||||
|
||||
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
|
||||
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
|
||||
an error — a stale tab must not break the app) and then logs that request at the session's level with
|
||||
every line tagged by its id; the browser tags its own events the same way. The issue is later built
|
||||
from exactly the rows carrying the tag, so what the review list shows is what gets sent.
|
||||
|
||||
Starting a session IS the consent for its level, for its duration: the start dialog carries the
|
||||
consent text. It needs no change to the user's own diagnostics level, and none is made — when the
|
||||
session ends the level in force is simply the user's own again.
|
||||
|
||||
A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over
|
||||
whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the
|
||||
hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`.
|
||||
|
||||
Core layer: imports `models`, `diagnostics`.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
from typing import Literal, get_args
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import and_, delete, func, not_, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app import diagnostics
|
||||
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
||||
|
||||
HEADER = "x-debug-session"
|
||||
MAX_MINUTES = diagnostics.SESSION_MINUTES
|
||||
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
|
||||
SCREENSHOTS_MAX = 8
|
||||
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
|
||||
# per-session cap alone would let Start → 8 images → Stop repeat without end.
|
||||
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
|
||||
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
|
||||
# accepted type always has a name.
|
||||
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
|
||||
# What a screenshot is called in the zip and on the issue. `issues/bundle.py` builds the names from
|
||||
# this and `IMAGE_TYPES`; this module and `issues/gitea.py` read them back to tell a screenshot's
|
||||
# link from a log's. One definition, because three copies of a name is how the next rename breaks
|
||||
# `images_pending` into silence.
|
||||
SCREENSHOT_PREFIX = "screenshot-"
|
||||
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
|
||||
# bundle says how many it left out rather than growing without bound.
|
||||
BUNDLE_MAX = 5_000
|
||||
|
||||
SessionLevel = Literal["DEBUG", "TRACE"]
|
||||
SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel)
|
||||
# Detail order of the diagnostics levels: a session raises the level in force, never lowers it.
|
||||
_DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2}
|
||||
_ID_LEN = 16
|
||||
|
||||
|
||||
class EndReason(StrEnum):
|
||||
STOPPED = "stopped"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
|
||||
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
|
||||
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def open_session(db: Session, user: User) -> DebugSession | None:
|
||||
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
|
||||
`start` and the hourly sweep are what close it."""
|
||||
session = _unclosed(db, user)
|
||||
if session is None or not is_live(session, datetime.now(timezone.utc)):
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
def _unclosed(db: Session, user: User) -> DebugSession | None:
|
||||
return db.scalar(
|
||||
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
|
||||
)
|
||||
|
||||
|
||||
def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
|
||||
"""Open a session, or return the live one — a second tab pressing Start joins the recording
|
||||
rather than ending it. One past its cap is closed first: the one-open-per-user index would
|
||||
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
|
||||
get here: the index refuses the second, and the caller joins the one that won."""
|
||||
now = datetime.now(timezone.utc)
|
||||
current = _unclosed(db, user)
|
||||
if current is not None:
|
||||
if is_live(current, now):
|
||||
return current
|
||||
end(current, EndReason.EXPIRED)
|
||||
db.flush()
|
||||
session = DebugSession(
|
||||
id=secrets.token_hex(_ID_LEN // 2),
|
||||
user_id=user.id,
|
||||
level=level,
|
||||
started_at=now,
|
||||
until=now + timedelta(minutes=MAX_MINUTES),
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
return session
|
||||
|
||||
|
||||
def stop(session: DebugSession) -> None:
|
||||
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
|
||||
not rename that."""
|
||||
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
|
||||
|
||||
|
||||
def end(session: DebugSession, reason: EndReason) -> None:
|
||||
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
|
||||
if session.ended_at is not None:
|
||||
return
|
||||
session.ended_at = (
|
||||
session.until
|
||||
if reason is EndReason.EXPIRED
|
||||
else min(datetime.now(timezone.utc), session.until)
|
||||
)
|
||||
session.ended_reason = reason
|
||||
|
||||
|
||||
def from_header(db: Session, user: User, value: str | None) -> DebugSession | None:
|
||||
"""The session a request names, if it is the caller's own and still live; else None."""
|
||||
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
|
||||
return None
|
||||
session = db.get(DebugSession, value)
|
||||
if (
|
||||
session is None
|
||||
or session.user_id != user.id
|
||||
or not is_live(session, datetime.now(timezone.utc))
|
||||
):
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]:
|
||||
"""The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the
|
||||
session's while one is named — whichever records more.
|
||||
|
||||
A named session also caps the expiry at its own: this pair is what the process tracks under the
|
||||
session (app/logs.py), and an expiry outliving the recording would hold the root logger down
|
||||
after it ended."""
|
||||
own = diagnostics.effective_level(user)
|
||||
until = user.diagnostics_until if own != diagnostics.BASE else None
|
||||
if session is None:
|
||||
return own, until
|
||||
if _DETAIL[session.level] <= _DETAIL[own]:
|
||||
return own, min(until, session.until) if until else session.until
|
||||
return session.level, session.until
|
||||
|
||||
|
||||
def lock(db: Session, session: DebugSession) -> None:
|
||||
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
|
||||
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
|
||||
in flight sees none of them change under it, and each of them sees a send that committed."""
|
||||
db.refresh(session, with_for_update=True)
|
||||
|
||||
|
||||
def save_draft(session: DebugSession, draft: dict) -> None:
|
||||
"""Keep the composer's draft (the caller validated, locked 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). A delivery's progress
|
||||
is not the composer's to replace, and is kept."""
|
||||
progress = (session.draft or {}).get("delivery")
|
||||
session.draft = {**draft, "delivery": progress} if progress else draft
|
||||
|
||||
|
||||
# --- a delivery's progress -----------------------------------------------------------------
|
||||
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
|
||||
# composer shows are one reading of it.
|
||||
def delivery(session: DebugSession) -> dict:
|
||||
return dict((session.draft or {}).get("delivery") or {})
|
||||
|
||||
|
||||
def not_uploaded(session: DebugSession) -> list[str]:
|
||||
"""The files a send that stopped half-way has still to upload — by name, as the issue carries
|
||||
them. What the composer names to the user."""
|
||||
return list(delivery(session).get("pending") or [])
|
||||
|
||||
|
||||
def images_pending(session: DebugSession) -> bool:
|
||||
"""Whether the issue's body still has to be edited to show the screenshots it already carries
|
||||
(every upload went, the edit that shows them did not)."""
|
||||
state = delivery(session)
|
||||
shown = state.get("images_shown")
|
||||
return not shown and any(n.startswith(SCREENSHOT_PREFIX) for n in state.get("links") or {})
|
||||
|
||||
|
||||
def unfinished(session: DebugSession) -> bool:
|
||||
"""Whether a send owes the issue anything — the retry's question."""
|
||||
return bool(not_uploaded(session)) or images_pending(session)
|
||||
|
||||
|
||||
# A send claims the session while it talks to the tracker, instead of holding the row's lock across
|
||||
# those calls: a Stop, an autosave or a screenshot would queue behind a lock for the whole send.
|
||||
# They see the claim and are refused at once instead. A claim not renewed for this long was left by
|
||||
# a request that died mid-send (the process restarted), and the next send takes it over — which is
|
||||
# why a live send RENEWS it every time it reports progress (`set_delivery`). A claim is a lease, and
|
||||
# a send slower than the lease must not lose it while it is still running.
|
||||
SEND_CLAIM_SECONDS = 600
|
||||
|
||||
|
||||
def _claim(session: DebugSession) -> dict:
|
||||
claim = delivery(session).get("sending")
|
||||
return claim if isinstance(claim, dict) else {}
|
||||
|
||||
|
||||
def _stamp(token: str) -> dict:
|
||||
return {"at": datetime.now(timezone.utc).isoformat(), "by": token}
|
||||
|
||||
|
||||
def sending(session: DebugSession) -> bool:
|
||||
"""Whether a send is under way right now (a live claim)."""
|
||||
at = _claim(session).get("at")
|
||||
if not at:
|
||||
return False
|
||||
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() < (
|
||||
SEND_CLAIM_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def claim_send(session: DebugSession) -> str:
|
||||
"""Mark the session as being sent, and return the token that OWNS this claim. The caller holds
|
||||
the lock and commits, which is what makes the claim visible to everyone else."""
|
||||
token = uuid4().hex
|
||||
_write_delivery(session, {**delivery(session), "sending": _stamp(token)})
|
||||
return token
|
||||
|
||||
|
||||
def release_send(session: DebugSession, token: str) -> None:
|
||||
"""Give back our OWN claim, and only ours: one that aged out and was taken over belongs to the
|
||||
send holding it now, and handing that one back would let a third send in beside it."""
|
||||
if _claim(session).get("by") != token:
|
||||
return
|
||||
_write_delivery(session, {k: v for k, v in delivery(session).items() if k != "sending"})
|
||||
|
||||
|
||||
class SendClaimLost(Exception):
|
||||
"""This send's claim is no longer on the row: it aged out and another send took it over. The
|
||||
send must stop — what it uploads from here it can no longer record, so a retry would send it
|
||||
again — and it must not write, because the row now holds the other send's progress."""
|
||||
|
||||
|
||||
def claim_owner(db: Session, session: DebugSession) -> str | None:
|
||||
"""Who holds the row's claim RIGHT NOW, read from the ROW and not from the object.
|
||||
|
||||
`expire_on_commit` is off (app/db.py), so a checkpoint does not re-read: a send that stalled
|
||||
past its lease still carries its own dead token in memory long after another send took over.
|
||||
A targeted scalar, not `lock`/`db.refresh` — a refresh here would discard the `issue_url` that
|
||||
`gitea.deliver` assigns one line before its first progress report, and that link is the one
|
||||
thing the send must not lose."""
|
||||
return db.scalar(
|
||||
select(DebugSession.draft["delivery"]["sending"]["by"].as_string()).where(
|
||||
DebugSession.id == session.id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> None:
|
||||
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it), and renew
|
||||
the claim while doing it — a send still reporting progress is still alive, so one slower than
|
||||
the lease cannot expire while it is still running.
|
||||
|
||||
The claim is not the caller's to carry: `gitea.deliver` threads ONE snapshot of this dict
|
||||
through a whole send, so a progress report that carried the claim wrote a stale one back at
|
||||
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
|
||||
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
|
||||
that lost its lease came back and stomped the take-over send's committed links, then put its
|
||||
own dead token back on an idle session (deep-review round 4). `release_send` has always made
|
||||
the same check; this is the other half of it."""
|
||||
if claim_owner(db, session) != token:
|
||||
raise SendClaimLost(session.id)
|
||||
state = {k: v for k, v in state.items() if k != "sending"}
|
||||
state["sending"] = _stamp(token)
|
||||
_write_delivery(session, state)
|
||||
|
||||
|
||||
def _write_delivery(session: DebugSession, state: dict) -> None:
|
||||
# A new dict, so the JSONB column sees the change.
|
||||
session.draft = {**(session.draft or {}), "delivery": state}
|
||||
|
||||
|
||||
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
|
||||
if session is None:
|
||||
return None
|
||||
return {
|
||||
"id": session.id,
|
||||
"level": session.level,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"until": session.until.isoformat(),
|
||||
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
||||
"ended_reason": session.ended_reason,
|
||||
"issue_url": session.issue_url,
|
||||
# A send that stopped half-way: the files it has still to upload, and whether the issue's
|
||||
# body has still to be edited to show the screenshots it already carries. Both are true of a
|
||||
# send that is RUNNING too — every step commits its progress — so `sending` goes with them:
|
||||
# without it a reload or a second tab renders a live send as a failed one, and offers a
|
||||
# retry that is certain to be refused.
|
||||
"pending_files": not_uploaded(session),
|
||||
"images_pending": images_pending(session),
|
||||
"sending": sending(session),
|
||||
"screenshots": screenshots,
|
||||
"draft": session.draft,
|
||||
}
|
||||
|
||||
|
||||
def view(db: Session, session: DebugSession) -> dict:
|
||||
"""The session as the API serves it — one shape wherever it is served."""
|
||||
return serialize(session, screenshot_count(db, session)) or {}
|
||||
|
||||
|
||||
def screenshot_count(db: Session, session: DebugSession) -> int:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(DebugScreenshot)
|
||||
.where(DebugScreenshot.session_id == session.id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
# --- screenshots ---------------------------------------------------------------------------
|
||||
# By content, not by the declared type: what is stored is served back to a browser as an image.
|
||||
_SIGNATURES = (
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg"),
|
||||
)
|
||||
|
||||
|
||||
def sniff(data: bytes) -> str | None:
|
||||
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
|
||||
for magic, mime in _SIGNATURES:
|
||||
if data.startswith(magic):
|
||||
return mime
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
return None
|
||||
|
||||
|
||||
def lock_user(db: Session, user_id: int) -> None:
|
||||
"""Hold this user's own lock for the rest of the transaction. A session's row lock bounds what
|
||||
THAT session may keep; the byte cap below is the user's across all of them, and a lock on one
|
||||
session cannot serialize a check that reads them all — two uploads to two sessions both read the
|
||||
total before either commits, and both pass."""
|
||||
db.execute(select(func.pg_advisory_xact_lock(user_id)))
|
||||
|
||||
|
||||
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
|
||||
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
|
||||
.where(DebugSession.user_id == user_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
class ScreenshotRefused(Exception):
|
||||
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
|
||||
"""Keep one screenshot. Locks the user and the session, and flushes; the caller commits.
|
||||
Refused while a send is under way or once the issue was sent (it can no longer carry it), past
|
||||
the per-session count or the user's storage, or when it is not an image we can show."""
|
||||
if not data:
|
||||
raise ScreenshotRefused("empty")
|
||||
mime = sniff(data)
|
||||
if mime is None:
|
||||
raise ScreenshotRefused("not_an_image")
|
||||
# Under both locks: two uploads at once cannot both pass the count (the session's), nor both
|
||||
# pass the user's byte cap from two different sessions (the user's). Always in this order.
|
||||
lock_user(db, session.user_id)
|
||||
lock(db, session)
|
||||
if sending(session):
|
||||
raise ScreenshotRefused("sending")
|
||||
if session.issue_url is not None:
|
||||
raise ScreenshotRefused("already_sent")
|
||||
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
|
||||
raise ScreenshotRefused("too_many")
|
||||
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
|
||||
raise ScreenshotRefused("storage_full")
|
||||
shot = DebugScreenshot(
|
||||
session_id=session.id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
mime=mime,
|
||||
size=len(data),
|
||||
data=data,
|
||||
)
|
||||
db.add(shot)
|
||||
db.flush()
|
||||
return shot
|
||||
|
||||
|
||||
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
|
||||
"""Delete one screenshot, under the lock. Refused while a send is under way or unfinished: a
|
||||
delivery names the screenshots by their order, and removing one would renumber what it still
|
||||
has to send. Once the issue has everything, this only drops OUR copy — the issue keeps its
|
||||
own. The caller commits."""
|
||||
lock(db, session)
|
||||
if sending(session):
|
||||
raise ScreenshotRefused("sending")
|
||||
if unfinished(session):
|
||||
raise ScreenshotRefused("already_sent")
|
||||
db.delete(shot)
|
||||
db.flush()
|
||||
|
||||
|
||||
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
|
||||
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
|
||||
return list(
|
||||
db.scalars(
|
||||
select(DebugScreenshot)
|
||||
.where(DebugScreenshot.session_id == session.id)
|
||||
.options(defer(DebugScreenshot.data))
|
||||
.order_by(DebugScreenshot.id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --- the evidence --------------------------------------------------------------------------
|
||||
# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence
|
||||
# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The
|
||||
# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`).
|
||||
INGEST_PATH = "/api/diagnostics/events"
|
||||
|
||||
|
||||
def _evidence(session: DebugSession):
|
||||
upload_line = and_(
|
||||
LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH
|
||||
)
|
||||
# `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and
|
||||
# `NOT NULL` would drop exactly the rows that are evidence.
|
||||
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
|
||||
|
||||
|
||||
def bundle(
|
||||
db: Session, session: DebugSession, *, drop_excluded: bool = False
|
||||
) -> tuple[list[LogEvent], int]:
|
||||
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
|
||||
how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
|
||||
the user took out in the composer: what an export or an issue carries."""
|
||||
where = list(_evidence(session))
|
||||
excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
|
||||
if excluded:
|
||||
where.append(LogEvent.id.not_in(excluded))
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
|
||||
)
|
||||
)
|
||||
total = len(rows)
|
||||
if total == BUNDLE_MAX:
|
||||
total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
|
||||
return rows, total
|
||||
|
||||
|
||||
# --- housekeeping (the hourly log job) -----------------------------------------------------
|
||||
def sweep(db: Session) -> int:
|
||||
"""Stage closing every open session past its cap; returns how many."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = db.execute(
|
||||
update(DebugSession)
|
||||
.where(DebugSession.ended_at.is_(None), DebugSession.until <= now)
|
||||
.values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def gc(db: Session, retention_days: int) -> int:
|
||||
"""Stage deleting sessions (and by cascade their screenshots) started before the log retention
|
||||
window — the rows they would bundle are gone by then too. Returns how many."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
|
||||
result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff))
|
||||
return result.rowcount or 0
|
||||
@@ -27,6 +27,9 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
|
||||
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
|
||||
BASE: DiagnosticsLevel = "INFO"
|
||||
ELEVATION_HOURS = 24
|
||||
# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other
|
||||
# consent window, because the start dialog's consent text states it and reads it from `state`.
|
||||
SESSION_MINUTES = 60
|
||||
|
||||
|
||||
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
|
||||
@@ -47,6 +50,7 @@ def state(db: Session, user: User) -> dict:
|
||||
"level": level,
|
||||
"until": user.diagnostics_until.isoformat() if level != BASE else None,
|
||||
"retention_days": sysconfig.get_int(db, "log_retention_days"),
|
||||
"session_minutes": SESSION_MINUTES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,79 @@
|
||||
"""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
|
||||
|
||||
|
||||
@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):
|
||||
ext = debugsession.IMAGE_TYPES[shot.mime]
|
||||
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
|
||||
out.append(IssueFile(name, 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:
|
||||
# The images are compressed already: deflating them again costs time and wins nothing.
|
||||
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
|
||||
z.writestr(item.name, item.data, compress_type=method)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1
|
||||
before a line of this was written (plans/e16-debug-issue.md, S0).
|
||||
|
||||
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
|
||||
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
|
||||
values, at Teljes what they typed into search — and a public repository would publish it. That is
|
||||
checked on every send, not only in the admin's "test connection", because a repository can be made
|
||||
public after the fact.
|
||||
|
||||
What an issue is made of comes from `bundle.files` (the same files the zip export carries). Sending
|
||||
is three kinds of call, measured:
|
||||
* `POST /repos/{repo}/issues` — the title and the body the user wrote;
|
||||
* `POST /repos/{repo}/issues/{n}/assets?name=…` — ONE file per request (field `attachment`);
|
||||
* `PATCH /repos/{repo}/issues/{n}` — the body again, with the screenshots shown inline once their
|
||||
links exist.
|
||||
A private repository's attachment answers 404 to a browser not signed in to Gitea, which is why the
|
||||
composer's own preview shows our copies, never these links.
|
||||
|
||||
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
|
||||
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
|
||||
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
|
||||
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
|
||||
next — and the first checkpoint comes before the first call, with the tracker's settings read ABOVE
|
||||
it, so neither a row lock nor a read snapshot nor a pooled connection is held across the calls. The
|
||||
caller claims the session for the send first (`debugsession.claim_send`) and passes the claim's
|
||||
token in, which is what keeps a second send, an autosave or a screenshot out of the way; the claim
|
||||
is renewed by every progress report, and a report whose claim is gone raises rather than writing
|
||||
over the send that took it. `debugsession.not_uploaded` / `images_pending` read that progress.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import debugsession, logs, sysconfig
|
||||
from app.issues import bundle
|
||||
from app.models import DebugSession
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 20.0
|
||||
|
||||
Reason = Literal[
|
||||
"not_configured", # url, repository or token missing
|
||||
"unreachable", # no answer, or a 5xx
|
||||
"bad_token", # 401/403: the token is wrong or lacks write:issue / read:repository
|
||||
"repo_not_found", # 404 on the repository
|
||||
"public_repo", # the repository is public — an issue would publish the reporter's log
|
||||
"issues_disabled", # the repository has its issue tracker switched off
|
||||
"rejected", # any other 4xx (a file type the instance does not allow, …), or an answer that is
|
||||
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
|
||||
]
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
def __init__(self, reason: Reason) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason: Reason = reason
|
||||
|
||||
|
||||
def _settings(db: Session) -> tuple[str, str, str]:
|
||||
return (
|
||||
sysconfig.get_str(db, "issues_gitea_url").strip().rstrip("/"),
|
||||
sysconfig.get_str(db, "issues_gitea_repo").strip().strip("/"),
|
||||
sysconfig.get_str(db, "issues_gitea_token").strip(),
|
||||
)
|
||||
|
||||
|
||||
def target(db: Session) -> dict | None:
|
||||
"""Where a report goes, for the composer to say so — the repository — or None when no tracker is
|
||||
configured (the report can then only be downloaded)."""
|
||||
url, repo, token = _settings(db)
|
||||
return {"repo": repo} if url and repo and token else None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
|
||||
# Takes the settings already read, not the session: reading them HERE opened a transaction one
|
||||
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
|
||||
url, repo, token = settings
|
||||
if not (url and repo and token):
|
||||
raise GiteaError("not_configured")
|
||||
with httpx.Client(
|
||||
base_url=f"{url}/api/v1",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
) as client:
|
||||
yield client, repo
|
||||
|
||||
|
||||
def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response:
|
||||
try:
|
||||
r = client.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning(
|
||||
"the issue tracker did not answer",
|
||||
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
|
||||
)
|
||||
raise GiteaError("unreachable") from None
|
||||
if r.status_code < 300:
|
||||
return r
|
||||
# The status only: Gitea's message can quote back what was sent — the user's own title.
|
||||
log.warning(
|
||||
"the issue tracker refused a call",
|
||||
extra=logs.event("issues.gitea.refused", path=path, status=r.status_code),
|
||||
)
|
||||
if r.status_code in (401, 403):
|
||||
raise GiteaError("bad_token")
|
||||
if r.status_code == 404:
|
||||
raise GiteaError("repo_not_found")
|
||||
if r.status_code >= 500:
|
||||
raise GiteaError("unreachable")
|
||||
raise GiteaError("rejected")
|
||||
|
||||
|
||||
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
|
||||
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash, so a
|
||||
step after the issue was created cannot take down the request that holds its record.
|
||||
|
||||
The CREATE call is the one place this cannot keep that record: its answer IS the record. An
|
||||
answer it cannot read there leaves the session unsent, and a retry opens another issue — the
|
||||
price of not being able to tell a refused create from an unreadable one."""
|
||||
try:
|
||||
data = r.json()
|
||||
# silent-ok: not JSON is one of the answers refused (and logged) just below.
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict) or any(k not in data for k in keys):
|
||||
log.warning(
|
||||
"the issue tracker answered something that is not its API",
|
||||
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
|
||||
)
|
||||
raise GiteaError("rejected")
|
||||
return data
|
||||
|
||||
|
||||
def _private_repo(client: httpx.Client, repo: str) -> dict:
|
||||
data = _json(_call(client, "GET", f"/repos/{repo}"))
|
||||
if not data.get("private"):
|
||||
raise GiteaError("public_repo")
|
||||
if not data.get("has_issues"):
|
||||
raise GiteaError("issues_disabled")
|
||||
return data
|
||||
|
||||
|
||||
def check(db: Session) -> dict:
|
||||
"""The admin's "test connection": the repository answers, is private and takes issues."""
|
||||
with _client(_settings(db)) as (client, repo):
|
||||
data = _private_repo(client, repo)
|
||||
return {"repo": data.get("full_name") or repo, "url": data.get("html_url")}
|
||||
|
||||
|
||||
def preview(db: Session, text: str) -> str:
|
||||
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser)."""
|
||||
with _client(_settings(db)) as (client, repo):
|
||||
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo})
|
||||
return r.text
|
||||
|
||||
|
||||
def deliver(
|
||||
db: Session,
|
||||
session: DebugSession,
|
||||
*,
|
||||
token: str,
|
||||
checkpoint: Callable[[], None],
|
||||
before_create: Callable[[], None] | None = None,
|
||||
on_created: Callable[[], None] | None = None,
|
||||
) -> str:
|
||||
"""Send the session's issue, or finish sending it; returns its URL.
|
||||
|
||||
`token` is the caller's claim on this session (`debugsession.claim_send`). Every progress report
|
||||
renews it and checks the row still holds it, so a send that outlived its lease stops here rather
|
||||
than writing over the one that took over (`debugsession.SendClaimLost`).
|
||||
|
||||
`checkpoint` persists what has happened so far and is called after every step (the caller's
|
||||
commit — this module does not decide when a transaction ends). `before_create` runs right before
|
||||
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
|
||||
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
|
||||
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
|
||||
lose separately from the link belongs there.
|
||||
"""
|
||||
state = debugsession.delivery(session)
|
||||
files = bundle.files(db, session)
|
||||
attachments = [f for f in files if f.name != "issue.md"]
|
||||
settings = _settings(db)
|
||||
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
|
||||
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
|
||||
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
|
||||
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
|
||||
# with its snapshot pinned — across the repository check and the create (deep-review round 4
|
||||
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
|
||||
checkpoint()
|
||||
with _client(settings) as (client, repo):
|
||||
_private_repo(client, repo)
|
||||
if "number" not in state:
|
||||
if before_create is not None:
|
||||
before_create()
|
||||
created = _json(
|
||||
_call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues",
|
||||
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
||||
),
|
||||
"number",
|
||||
"html_url",
|
||||
)
|
||||
# Built ON the state read at the start so nothing already in it is dropped. The claim
|
||||
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
|
||||
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
|
||||
# the spread left the whole suite green, because the claim never travelled here).
|
||||
state = {
|
||||
**state,
|
||||
"number": created["number"],
|
||||
"pending": [f.name for f in attachments],
|
||||
"links": {},
|
||||
}
|
||||
session.issue_url = created["html_url"]
|
||||
try:
|
||||
debugsession.set_delivery(db, session, state, token)
|
||||
except debugsession.SendClaimLost:
|
||||
# The one step where losing the claim costs something irreversible: the issue is
|
||||
# already open on the tracker and this is what would have recorded its link. We
|
||||
# still must not write — another send owns the row and is opening its own issue —
|
||||
# so say which issue was left behind, because nothing else will.
|
||||
# noqa TRY400: the traceback is one SendClaimLost carrying the session id and
|
||||
# nothing else; the issue number below is the whole news.
|
||||
log.error( # noqa: TRY400
|
||||
"an issue was created by a send that had already lost its claim",
|
||||
extra=logs.event(
|
||||
"issues.gitea.orphaned",
|
||||
number=created["number"],
|
||||
session_id=session.id,
|
||||
),
|
||||
)
|
||||
raise
|
||||
if on_created is not None:
|
||||
on_created()
|
||||
checkpoint()
|
||||
log.info(
|
||||
"issue created",
|
||||
extra=logs.event("issues.gitea.created", number=created["number"]),
|
||||
)
|
||||
number = state["number"]
|
||||
for item in attachments:
|
||||
if item.name not in state["pending"]:
|
||||
continue
|
||||
asset = _json(
|
||||
_call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assets",
|
||||
params={"name": item.name},
|
||||
files={"attachment": (item.name, item.data, item.mime)},
|
||||
),
|
||||
"browser_download_url",
|
||||
)
|
||||
state = {
|
||||
**state,
|
||||
"pending": [n for n in state["pending"] if n != item.name],
|
||||
"links": {**state["links"], item.name: asset["browser_download_url"]},
|
||||
}
|
||||
debugsession.set_delivery(db, session, state, token)
|
||||
checkpoint()
|
||||
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
|
||||
if shots and not state.get("images_shown"):
|
||||
images = "\n".join(f"" for n in sorted(shots))
|
||||
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"
|
||||
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
|
||||
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
|
||||
checkpoint()
|
||||
return session.issue_url or ""
|
||||
+48
-12
@@ -100,12 +100,14 @@ class _RequestScope:
|
||||
request; every copied context shares the same object, so what the dependency writes is seen by
|
||||
the handler and by the request line itself."""
|
||||
|
||||
__slots__ = ("user_id", "level")
|
||||
__slots__ = ("user_id", "level", "debug_session_id")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.user_id: int | None = None
|
||||
# The user's consented diagnostics level as a logging number; None at the base level.
|
||||
self.level: int | None = None
|
||||
# The user's own open debug session this request named (E16), else None.
|
||||
self.debug_session_id: str | None = None
|
||||
|
||||
|
||||
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
||||
@@ -113,11 +115,23 @@ _scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
||||
)
|
||||
|
||||
|
||||
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None:
|
||||
def set_user(
|
||||
user_id: int | None,
|
||||
level: str = "INFO",
|
||||
until: datetime | None = None,
|
||||
debug_session: str | None = None,
|
||||
) -> None:
|
||||
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
|
||||
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
|
||||
auth dependency — the one place the session is actually resolved into an account — rather than
|
||||
from the request middleware, which runs OUTSIDE the session middleware."""
|
||||
from the request middleware, which runs OUTSIDE the session middleware.
|
||||
|
||||
`debug_session` tags every line with the user's open debug session (E16). The caller has
|
||||
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
|
||||
|
||||
The root logger's floor is tracked per elevation: a request without the session's header (an
|
||||
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
|
||||
must not lower the floor a concurrent request of the session still needs."""
|
||||
_user_id.set(user_id)
|
||||
scope = _scope.get()
|
||||
if scope is None: # outside log_requests (a script, a unit test): this context only
|
||||
@@ -125,9 +139,11 @@ def set_user(user_id: int | None, level: str = "INFO", until: datetime | None =
|
||||
_scope.set(scope)
|
||||
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
|
||||
scope.user_id = user_id
|
||||
scope.debug_session_id = debug_session
|
||||
scope.level = resolve_level(level) if elevated else None
|
||||
if user_id is not None:
|
||||
_track_elevation(user_id, scope.level, until)
|
||||
key = user_id if debug_session is None else (user_id, debug_session)
|
||||
_track_elevation(key, scope.level, until)
|
||||
|
||||
|
||||
def set_job(job_id: int | None) -> None:
|
||||
@@ -328,6 +344,12 @@ class ContextFilter(logging.Filter):
|
||||
scope = _scope.get()
|
||||
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
|
||||
record.user_id = scope.user_id
|
||||
if (
|
||||
scope is not None
|
||||
and scope.debug_session_id is not None
|
||||
and not hasattr(record, "debug_session_id")
|
||||
):
|
||||
record.debug_session_id = scope.debug_session_id
|
||||
return True
|
||||
|
||||
|
||||
@@ -545,18 +567,21 @@ def current_level() -> str:
|
||||
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
|
||||
# test, a script — so the gate lets everything through there, as it did before the gate existed.
|
||||
_base_level = logging.NOTSET
|
||||
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
|
||||
_elevations: dict[int, tuple[int, datetime]] = {}
|
||||
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
|
||||
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
|
||||
# its level, so it is tracked apart from the user's own requests.
|
||||
_ElevationKey = int | tuple[int, str]
|
||||
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
|
||||
_elevations_lock = threading.Lock()
|
||||
_root_applied: int | None = None
|
||||
|
||||
|
||||
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
|
||||
def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
|
||||
with _elevations_lock:
|
||||
if level is None or until is None:
|
||||
_elevations.pop(user_id, None)
|
||||
_elevations.pop(key, None)
|
||||
else:
|
||||
_elevations[user_id] = (level, until)
|
||||
_elevations[key] = (level, until)
|
||||
active = bool(_elevations)
|
||||
if active or _root_applied != _base_level:
|
||||
_apply_root_level()
|
||||
@@ -573,8 +598,8 @@ def _apply_root_level(force: bool = False) -> None:
|
||||
# different answer would otherwise apply them in either order, and the older one could win —
|
||||
# the root back at the base while an elevation is still active.
|
||||
with _elevations_lock:
|
||||
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
|
||||
del _elevations[user_id]
|
||||
for key in [k for k, (_, until) in _elevations.items() if until <= now]:
|
||||
del _elevations[key]
|
||||
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
|
||||
if force or lowest != _root_applied:
|
||||
logging.getLogger().setLevel(lowest)
|
||||
@@ -611,13 +636,24 @@ def forget_user(user_id: int) -> None:
|
||||
del _erased[stale]
|
||||
_erased[user_id] = now
|
||||
with _elevations_lock:
|
||||
_elevations.pop(user_id, None)
|
||||
for key in [
|
||||
k for k in _elevations if k == user_id or (isinstance(k, tuple) and k[0] == user_id)
|
||||
]:
|
||||
del _elevations[key]
|
||||
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
|
||||
# and every unexpired one. Recomputed here rather than left to whichever later request happens
|
||||
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
|
||||
_apply_root_level()
|
||||
|
||||
|
||||
def end_session_elevation(session_id: str) -> None:
|
||||
"""A debug session was stopped: its elevation ends now, not at its cap."""
|
||||
with _elevations_lock:
|
||||
for key in [k for k in _elevations if isinstance(k, tuple) and k[1] == session_id]:
|
||||
del _elevations[key]
|
||||
_apply_root_level()
|
||||
|
||||
|
||||
def is_erased(user_id: int | None) -> bool:
|
||||
if user_id is None:
|
||||
return False
|
||||
|
||||
@@ -44,6 +44,7 @@ from app import logs
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.models import LogEvent
|
||||
from app.utils import iso
|
||||
|
||||
QUEUE_MAX = 10_000
|
||||
BATCH_MAX = 500
|
||||
@@ -343,3 +344,24 @@ def gc(db: Session, retention_days: int) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
|
||||
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def serialize(row: LogEvent) -> dict:
|
||||
"""A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and
|
||||
a debug session's evidence list (E16)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"ts": iso(row.ts),
|
||||
"level": row.level,
|
||||
"source": row.source,
|
||||
"logger": row.logger,
|
||||
"event": row.event,
|
||||
"msg": row.msg,
|
||||
"attrs": row.attrs,
|
||||
"exc": row.exc,
|
||||
"request_id": row.request_id,
|
||||
"user_id": row.user_id,
|
||||
"job_id": row.job_id,
|
||||
"debug_session_id": row.debug_session_id,
|
||||
"version": row.version,
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.routes import (
|
||||
auth as auth_routes,
|
||||
channels,
|
||||
config as config_routes,
|
||||
debug_sessions as debug_sessions_routes,
|
||||
diagnostics as diagnostics_routes,
|
||||
discover as discover_routes,
|
||||
downloads,
|
||||
@@ -273,6 +274,7 @@ app.include_router(discover_routes.router)
|
||||
app.include_router(search_routes.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(diagnostics_routes.router)
|
||||
app.include_router(debug_sessions_routes.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(channels.router)
|
||||
|
||||
+56
-1
@@ -12,6 +12,7 @@ from sqlalchemy import (
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
LargeBinary,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
@@ -565,7 +566,15 @@ class LogEvent(Base):
|
||||
the retention job (`log_retention_days`) bounds whatever another process writes late."""
|
||||
|
||||
__tablename__ = "log_events"
|
||||
__table_args__ = (Index("ix_log_events_user_ts", "user_id", "ts"),)
|
||||
__table_args__ = (
|
||||
Index("ix_log_events_user_ts", "user_id", "ts"),
|
||||
# E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
|
||||
Index(
|
||||
"ix_log_events_debug_session",
|
||||
"debug_session_id",
|
||||
postgresql_where="debug_session_id IS NOT NULL",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
@@ -584,6 +593,52 @@ class LogEvent(Base):
|
||||
version: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DebugSession(Base):
|
||||
"""One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every
|
||||
request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`,
|
||||
and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the
|
||||
server set at Start; an open session past it is over whether or not the sweep has closed it yet.
|
||||
One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`)."""
|
||||
|
||||
__tablename__ = "debug_sessions"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_debug_sessions_open_per_user",
|
||||
"user_id",
|
||||
unique=True,
|
||||
postgresql_where="ended_at IS NULL",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
level: Mapped[str] = mapped_column(String(8))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
until: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
|
||||
ended_reason: Mapped[str | None] = mapped_column(String(16))
|
||||
issue_url: Mapped[str | None] = mapped_column(String(512))
|
||||
# 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):
|
||||
"""A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill.
|
||||
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
|
||||
|
||||
__tablename__ = "debug_screenshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
mime: Mapped[str] = mapped_column(String(16))
|
||||
size: Mapped[int] = mapped_column(Integer)
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
|
||||
|
||||
class AppState(Base):
|
||||
"""Single-row global app state (admin-controlled)."""
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ one existence-leak policy.
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base
|
||||
from app.models import User
|
||||
from app.issues import gitea
|
||||
from app.refusal import refused
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
@@ -63,3 +65,36 @@ def youtube_refusal(exc: Exception) -> HTTPException:
|
||||
YouTube being unavailable to us — the retryable answer."""
|
||||
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
|
||||
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
|
||||
|
||||
|
||||
async def capped_body(request: Request, limit: int) -> bytes:
|
||||
"""The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still
|
||||
arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or
|
||||
wrong, and counting stops reading at the same point a declared-length check would have."""
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body += chunk
|
||||
if len(body) > limit:
|
||||
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
|
||||
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
|
||||
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
|
||||
them to require a sentence for each in both languages."""
|
||||
match exc.reason:
|
||||
case "not_configured":
|
||||
return refused(400, "issues_not_configured")
|
||||
case "unreachable":
|
||||
return refused(422, "issues_unreachable")
|
||||
case "bad_token":
|
||||
return refused(422, "issues_bad_token")
|
||||
case "repo_not_found":
|
||||
return refused(422, "issues_repo_not_found")
|
||||
case "public_repo":
|
||||
return refused(422, "issues_public_repo")
|
||||
case "issues_disabled":
|
||||
return refused(422, "issues_disabled")
|
||||
case _:
|
||||
return refused(422, "issues_rejected")
|
||||
|
||||
@@ -26,10 +26,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logsink
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import LogEvent, User
|
||||
from app.utils import iso, like_escape
|
||||
from app.utils import like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
||||
|
||||
@@ -66,25 +67,6 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||
|
||||
|
||||
def _serialize(row: LogEvent) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"ts": iso(row.ts),
|
||||
"level": row.level,
|
||||
"source": row.source,
|
||||
"logger": row.logger,
|
||||
"event": row.event,
|
||||
"msg": row.msg,
|
||||
"attrs": row.attrs,
|
||||
"exc": row.exc,
|
||||
"request_id": row.request_id,
|
||||
"user_id": row.user_id,
|
||||
"job_id": row.job_id,
|
||||
"debug_session_id": row.debug_session_id,
|
||||
"version": row.version,
|
||||
}
|
||||
|
||||
|
||||
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
||||
line was written) is simply absent, and the client shows the bare id."""
|
||||
@@ -196,7 +178,7 @@ def list_logs(
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return {
|
||||
"items": [_serialize(r) for r in rows],
|
||||
"items": [logsink.serialize(r) for r in rows],
|
||||
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
from app.issues import gitea
|
||||
from app.routes._common import issue_refused, not_found
|
||||
from app.refusal import setting_refused
|
||||
|
||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||
@@ -131,3 +132,16 @@ def send_test_email(
|
||||
if not sent:
|
||||
raise refused(422, "smtp_failed")
|
||||
return {"ok": True, "sent": True, "to": user.email}
|
||||
|
||||
|
||||
@router.post("/test-issues")
|
||||
def test_issue_tracker(
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Check the configured issue tracker (E16): the repository answers with this token, takes
|
||||
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
|
||||
try:
|
||||
return {"ok": True, **gitea.check(db)}
|
||||
except gitea.GiteaError as exc:
|
||||
raise issue_refused(exc) from None
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review.
|
||||
|
||||
The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes
|
||||
are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including
|
||||
for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account
|
||||
cannot record: its log would interleave every visitor under one account.
|
||||
|
||||
Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart
|
||||
form — one image per request, capped while it arrives, and kept only if its bytes really are one of
|
||||
those types, whatever the Content-Type claimed.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, debugsession, logs, logsink
|
||||
from app.audit import AuditAction
|
||||
from app.auth import require_human
|
||||
from app.db import get_db
|
||||
from app.issues import bundle, gitea
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.refusal import refused
|
||||
from app.models import DebugScreenshot, DebugSession, User
|
||||
from app.routes._common import capped_body, issue_refused, not_found, owned_or_404
|
||||
from app.utils import iso
|
||||
|
||||
router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"])
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
# Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft.
|
||||
LIST_MAX = 20
|
||||
|
||||
|
||||
class StartIn(BaseModel):
|
||||
level: debugsession.SessionLevel
|
||||
|
||||
|
||||
_view = debugsession.view
|
||||
|
||||
# New sessions a user may open per hour (joining the running one is not counted): a session is a
|
||||
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
|
||||
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def start_session(
|
||||
body: StartIn,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Start recording, or join the recording already running (another tab started it)."""
|
||||
joining = debugsession.open_session(db, user) is not None
|
||||
if not joining and not _new_sessions.allow(str(user.id)):
|
||||
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
|
||||
try:
|
||||
session = debugsession.start(db, user, body.level)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent Start won the one-open-per-user index: join that one.
|
||||
db.rollback()
|
||||
session = debugsession.open_session(db, user)
|
||||
if session is None:
|
||||
raise refused(409, "debug_session_conflict") from None
|
||||
# The rest of THIS request is part of the session too.
|
||||
level, until = debugsession.level_for(user, session)
|
||||
logs.set_user(user.id, level, until, session.id)
|
||||
log.info(
|
||||
"debug session started",
|
||||
extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)),
|
||||
)
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
|
||||
|
||||
@router.post("/{sid}/stop")
|
||||
def stop_session(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
if session.ended_at is None:
|
||||
# Logged BEFORE the session closes, while this request still carries its tag: the last line
|
||||
# of the evidence is the Stop itself.
|
||||
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
|
||||
debugsession.stop(session)
|
||||
db.commit()
|
||||
logs.end_session_elevation(session.id)
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
sessions = db.scalars(
|
||||
select(DebugSession)
|
||||
.where(DebugSession.user_id == user.id)
|
||||
.order_by(DebugSession.started_at.desc())
|
||||
.limit(LIST_MAX)
|
||||
).all()
|
||||
return {"items": [_view(db, s) for s in sessions]}
|
||||
|
||||
|
||||
@router.get("/{sid}")
|
||||
def get_session(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
|
||||
|
||||
|
||||
@router.get("/{sid}/events")
|
||||
def session_events(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""The rows an issue from this session would carry — browser and server, in time order."""
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
rows, total = debugsession.bundle(db, session)
|
||||
return {"items": [logsink.serialize(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
# --- 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
|
||||
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
|
||||
_exports = RateLimiter(max_events=6, window_seconds=60)
|
||||
|
||||
|
||||
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 len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||
# Under the lock, and refused while a send is under way: the issue is being built from the draft
|
||||
# as it was saved, and this would change it under the send.
|
||||
debugsession.lock(db, session)
|
||||
if debugsession.sending(session):
|
||||
raise refused(409, "issue_sending")
|
||||
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 refused(409, "issue_already_sent")
|
||||
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")
|
||||
if not _exports.allow(str(user.id)):
|
||||
raise refused(429, "export_rate_limited")
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- the issue tracker (S4) -----------------------------------------------------------------
|
||||
# Issues a user may open per hour — counted once the tracker is ASKED to open one (an answer that
|
||||
# goes missing may still have created it, so an attempt cannot be given back); finishing a half-sent
|
||||
# one, or an attempt refused before the tracker was asked, is not counted. The composer sends one at
|
||||
# a time; this is room for a bad afternoon, not a pace anything legitimate keeps up.
|
||||
_new_issues = RateLimiter(max_events=5, window_seconds=3600)
|
||||
# Attempts on the tracker per hour, creating or finishing. Deliberately NOT the budget above: a
|
||||
# half-sent issue must always be finishable, so the pace that guards creating one must never be what
|
||||
# stands between a user and their own report. This one only bounds a loop — every attempt re-reads
|
||||
# the whole bundle into memory and re-offers the pending file to the tracker, and a retry that is
|
||||
# refused for good (the tracker's own limits) never leaves `pending`. No hand reaches it.
|
||||
_sends = RateLimiter(max_events=60, window_seconds=3600)
|
||||
# The composer's live preview, rendered BY the tracker: one authenticated call each, with our token.
|
||||
# The composer's toggle fires it once per switch INTO preview — the editor is the other branch, so
|
||||
# no keystroke can change the query key while the preview is up — and react-query's cache makes an
|
||||
# unchanged draft free. (It does NOT debounce, whatever this comment used to say: a key that changes
|
||||
# per keystroke is one fetch per keystroke, measured. Showing the preview BESIDE the fields would
|
||||
# need a real debounce first.) This bounds what a loop can aim at the tracker.
|
||||
_previews = RateLimiter(max_events=60, window_seconds=60)
|
||||
|
||||
|
||||
def _issues_rate_limited() -> HTTPException:
|
||||
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
|
||||
|
||||
|
||||
@router.post("/{sid}/issue")
|
||||
def send_issue(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
|
||||
finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
# The session is CLAIMED for the send rather than locked through it: the tracker is slow (a
|
||||
# dozen calls, 20 s each), and a row lock held across them would queue this user's Stop, their
|
||||
# autosave and their screenshots behind it. They see the claim instead and are refused at once.
|
||||
debugsession.lock(db, session)
|
||||
if debugsession.sending(session):
|
||||
raise refused(409, "issue_sending")
|
||||
new = session.issue_url is None
|
||||
if not new and not debugsession.unfinished(session):
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
key = str(user.id)
|
||||
if new:
|
||||
if not ((session.draft or {}).get("title") or "").strip():
|
||||
raise refused(422, "issue_title_required")
|
||||
if _new_issues.exhausted(key):
|
||||
raise _issues_rate_limited()
|
||||
if not _sends.allow(key):
|
||||
raise refused(429, "issue_rate_limited", limit=_sends.max_events)
|
||||
token = debugsession.claim_send(session)
|
||||
db.commit() # the claim is what everything else sees; the lock ends here
|
||||
|
||||
def count_new_issue() -> None:
|
||||
if not _new_issues.allow(key):
|
||||
raise _issues_rate_limited()
|
||||
|
||||
try:
|
||||
gitea.deliver(
|
||||
db,
|
||||
session,
|
||||
token=token,
|
||||
checkpoint=db.commit,
|
||||
before_create=count_new_issue,
|
||||
on_created=lambda: _record_created(db, user, session),
|
||||
)
|
||||
except debugsession.SendClaimLost:
|
||||
# This send outlived its lease and another one took the session over while it was still
|
||||
# talking to the tracker. It stopped rather than writing over that one's progress — and a
|
||||
# send IS under way, just not ours, which is what the caller needs to hear.
|
||||
raise refused(409, "issue_sending") from None
|
||||
except gitea.GiteaError as exc:
|
||||
# Keep what did happen: an issue created before an upload failed stays linked, and the
|
||||
# retry does only what is still missing.
|
||||
raise issue_refused(exc) from None
|
||||
except HTTPException:
|
||||
raise # the pace, refused before the tracker was asked
|
||||
finally:
|
||||
_end_send(db, session, token)
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
|
||||
|
||||
def _end_send(db: Session, session: DebugSession, token: str) -> None:
|
||||
"""Give the claim back, whatever else happened. A failure here must not replace the failure that
|
||||
brought us: a dead transaction (the database went away mid-send) is rolled back, and the claim
|
||||
then simply ages out."""
|
||||
try:
|
||||
debugsession.lock(db, session)
|
||||
debugsession.release_send(session, token)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — whatever went wrong here, the send's own failure is the news
|
||||
db.rollback()
|
||||
log.warning(
|
||||
"a send could not give its claim back",
|
||||
extra=logs.event("issues.claim.stuck", session_id=session.id),
|
||||
)
|
||||
|
||||
|
||||
def _record_created(db: Session, user: User, session: DebugSession) -> None:
|
||||
"""The trail for the one act that takes a user's log off this instance. Staged inside the send,
|
||||
so the checkpoint that first writes `issue_url` commits BOTH — recorded later it could be lost
|
||||
on its own (the process dies mid-send), and no retry could write it afterwards: the next send
|
||||
sees the link and knows the issue is not new."""
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.ISSUE_CREATE,
|
||||
target_type="debug_session",
|
||||
target_id=session.id,
|
||||
summary=session.issue_url or "",
|
||||
)
|
||||
|
||||
|
||||
class PreviewIn(BaseModel):
|
||||
text: str = Field(max_length=50_000)
|
||||
|
||||
|
||||
@router.post("/{sid}/preview")
|
||||
def preview_issue(
|
||||
sid: str,
|
||||
body: PreviewIn,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The body as the tracker will render it. Gitea sanitises its own output; the composer still
|
||||
shows it in a sandboxed frame, never in the app's own document."""
|
||||
owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
if not _previews.allow(str(user.id)):
|
||||
raise refused(429, "preview_rate_limited")
|
||||
try:
|
||||
return {"ok": True, "html": gitea.preview(db, body.text)}
|
||||
except gitea.GiteaError as exc:
|
||||
raise issue_refused(exc) from None
|
||||
|
||||
|
||||
# --- screenshots ---------------------------------------------------------------------------
|
||||
def _shot_view(shot: DebugScreenshot) -> dict:
|
||||
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
|
||||
|
||||
|
||||
@router.get("/{sid}/screenshots")
|
||||
def list_screenshots(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
|
||||
|
||||
|
||||
def _upload_target(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> DebugSession:
|
||||
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
|
||||
|
||||
async def _image_body(request: Request) -> bytes:
|
||||
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
|
||||
|
||||
|
||||
@router.post("/{sid}/screenshots")
|
||||
def add_screenshot(
|
||||
# The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused
|
||||
# before a byte of it is read.
|
||||
session: DebugSession = Depends(_upload_target),
|
||||
data: bytes = Depends(_image_body),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
shot = debugsession.add_screenshot(db, session, data)
|
||||
except debugsession.ScreenshotRefused as exc:
|
||||
raise _screenshot_refused(exc.code) from None
|
||||
db.commit()
|
||||
return {"ok": True, "screenshot": _shot_view(shot)}
|
||||
|
||||
|
||||
def _screenshot_refused(code: str) -> HTTPException:
|
||||
# Literal reasons, one per case: the frontend's contract test reads them to require a sentence
|
||||
# for each in both languages.
|
||||
if code == "already_sent":
|
||||
return refused(409, "screenshot_already_sent")
|
||||
if code == "too_many":
|
||||
return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX)
|
||||
if code == "sending":
|
||||
return refused(409, "issue_sending")
|
||||
if code == "storage_full":
|
||||
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
|
||||
return refused(422, "screenshot_storage_full", limit=mb)
|
||||
if code == "empty":
|
||||
return refused(422, "screenshot_empty")
|
||||
return refused(422, "screenshot_not_an_image")
|
||||
|
||||
|
||||
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
|
||||
return _screenshot_of(
|
||||
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
|
||||
)
|
||||
|
||||
|
||||
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
|
||||
shot = db.get(DebugScreenshot, shot_id)
|
||||
if shot is None or shot.session_id != session.id:
|
||||
raise not_found("screenshot")
|
||||
return shot
|
||||
|
||||
|
||||
@router.get("/{sid}/screenshots/{shot_id}")
|
||||
def get_screenshot(
|
||||
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> Response:
|
||||
shot = _session_screenshot(db, sid, shot_id, user)
|
||||
# `nosniff`: the type was checked against the bytes at upload, and the browser must not guess.
|
||||
return Response(
|
||||
content=shot.data,
|
||||
media_type=shot.mime,
|
||||
headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{sid}/screenshots/{shot_id}")
|
||||
def delete_screenshot(
|
||||
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
shot = _screenshot_of(db, session, shot_id)
|
||||
try:
|
||||
debugsession.remove_screenshot(db, session, shot)
|
||||
except debugsession.ScreenshotRefused as exc:
|
||||
raise _screenshot_refused(exc.code) from None
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": shot_id}
|
||||
@@ -13,7 +13,9 @@ Nothing from the body is trusted as-is:
|
||||
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
|
||||
modified client would not;
|
||||
* an event below the consented level is dropped (a Részletes consent does not store what only
|
||||
Teljes may carry), and so is the whole batch once the level has lapsed;
|
||||
Teljes may carry), and so is the whole batch once the level has lapsed — where an open debug
|
||||
session the request names counts as consent to ITS level (E16), and only its own events keep
|
||||
its tag;
|
||||
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
|
||||
claiming to come from the future.
|
||||
"""
|
||||
@@ -27,11 +29,12 @@ from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import diagnostics, logs, logsink
|
||||
from app import debugsession, diagnostics, logs, logsink
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import LogEvent, User
|
||||
from app.models import DebugSession, LogEvent, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes._common import capped_body
|
||||
|
||||
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
||||
|
||||
@@ -60,6 +63,9 @@ class ClientEvent(BaseModel):
|
||||
attrs: dict[str, Any] | None = None
|
||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$")
|
||||
tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$")
|
||||
# The debug session the event was captured in (E16); kept only when it is the one this request
|
||||
# names in `X-Debug-Session` and the auth dependency found it the caller's own and open.
|
||||
debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$")
|
||||
version: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
@@ -67,18 +73,6 @@ class ClientEventsIn(BaseModel):
|
||||
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
|
||||
|
||||
|
||||
async def _capped_body(request: Request) -> bytes:
|
||||
"""The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted,
|
||||
not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting
|
||||
stops reading at the same point a declared-length check would have."""
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body += chunk
|
||||
if len(body) > BODY_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||
return bytes(body)
|
||||
|
||||
|
||||
async def _parsed_events(request: Request) -> ClientEventsIn:
|
||||
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
|
||||
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
|
||||
@@ -89,14 +83,20 @@ async def _parsed_events(request: Request) -> ClientEventsIn:
|
||||
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if media_type != "application/json" and not media_type.endswith("+json"):
|
||||
raise HTTPException(status_code=415, detail={"code": "json_required"})
|
||||
raw = await _capped_body(request)
|
||||
raw = await capped_body(request, BODY_MAX_BYTES)
|
||||
try:
|
||||
return ClientEventsIn.model_validate_json(raw)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||
|
||||
|
||||
def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]:
|
||||
def _row(
|
||||
item: ClientEvent,
|
||||
user: User,
|
||||
now: datetime,
|
||||
window_start: datetime,
|
||||
session: DebugSession | None,
|
||||
) -> dict[str, Any]:
|
||||
attrs = logs.redact(item.attrs) if item.attrs else None
|
||||
if attrs is not None:
|
||||
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
|
||||
@@ -117,6 +117,9 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -
|
||||
"attrs": attrs,
|
||||
"request_id": item.request_id,
|
||||
"user_id": user.id,
|
||||
"debug_session_id": (
|
||||
session.id if session and item.debug_session_id == session.id else None
|
||||
),
|
||||
"version": item.version,
|
||||
}
|
||||
)
|
||||
@@ -126,11 +129,15 @@ def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -
|
||||
def ingest_events(
|
||||
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
|
||||
# byte of its body is read.
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
body: ClientEventsIn = Depends(_parsed_events),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
level = diagnostics.effective_level(user)
|
||||
# A debug session the request names raises the level for its own events (E16): starting one is
|
||||
# the consent to that level for its duration, whatever the user's own level is.
|
||||
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
||||
level, _ = debugsession.level_for(user, session)
|
||||
if level == diagnostics.BASE:
|
||||
# Lapsed or withdrawn: the client stops streaming when it sees this.
|
||||
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
|
||||
@@ -140,7 +147,7 @@ def ingest_events(
|
||||
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
|
||||
floor = logs.SEVERITY[level]
|
||||
rows = [
|
||||
_row(item, user, now, window_start)
|
||||
_row(item, user, now, window_start, session)
|
||||
for item in body.events
|
||||
if logs.SEVERITY[item.level] >= floor
|
||||
]
|
||||
|
||||
@@ -17,7 +17,8 @@ from app.auth import (
|
||||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
from app import diagnostics
|
||||
from app import debugsession, diagnostics
|
||||
from app.issues import gitea
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
from app import sysconfig
|
||||
@@ -158,9 +159,18 @@ def get_me(
|
||||
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
|
||||
# it to its own log and streams that log while it is raised.
|
||||
"diagnostics": diagnostics.state(db, user),
|
||||
# The live debug session, if one is recording (E16) — a reload resumes it.
|
||||
"debug_session": _live_session(db, user),
|
||||
# Where a bug report goes (E16 S4): the tracker's repository, or None — download only.
|
||||
"issues": gitea.target(db),
|
||||
}
|
||||
|
||||
|
||||
def _live_session(db: Session, user: User) -> dict | None:
|
||||
session = debugsession.open_session(db, user)
|
||||
return None if session is None else debugsession.view(db, session)
|
||||
|
||||
|
||||
@router.delete("/account")
|
||||
def delete_my_account(
|
||||
request: Request,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Callable
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit, logs, logsink, progress, quota
|
||||
from app import audit, debugsession, logs, logsink, progress, quota
|
||||
from app.audit import AuditAction
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
@@ -386,9 +386,13 @@ def _log_gc_job() -> None:
|
||||
def work(db):
|
||||
from app import sysconfig
|
||||
|
||||
reaped = logsink.gc(db, sysconfig.get_int(db, "log_retention_days"))
|
||||
days = sysconfig.get_int(db, "log_retention_days")
|
||||
reaped = logsink.gc(db, days)
|
||||
# Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed.
|
||||
closed = debugsession.sweep(db)
|
||||
sessions = debugsession.gc(db, days)
|
||||
db.commit()
|
||||
return {"reaped": reaped}
|
||||
return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions}
|
||||
|
||||
_job("log_gc", work)
|
||||
|
||||
|
||||
@@ -192,6 +192,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
|
||||
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
|
||||
# --- Bug reports (E16): the Gitea issue tracker a debug session's issue goes to ---
|
||||
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
|
||||
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
|
||||
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
|
||||
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
|
||||
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
|
||||
)
|
||||
|
||||
@@ -111,8 +111,11 @@ def _request_dep(db, unfinished: list[str]):
|
||||
request that ends normally with a write it did not commit — flushed, or still only staged — is
|
||||
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
|
||||
savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the
|
||||
test's business, not the handler's. A request that raises is exempt: its writes are meant to
|
||||
roll back."""
|
||||
test's business, not the handler's. A request that raises is rolled back, as production's
|
||||
`close()` rolls it back: without that, what a failing handler flushed but never committed stayed
|
||||
readable to the rest of the test — the one state production cannot be in (E16: an issue created
|
||||
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
|
||||
missing)."""
|
||||
|
||||
def dep(conn: HTTPConnection):
|
||||
db.flush()
|
||||
@@ -133,6 +136,9 @@ def _request_dep(db, unfinished: list[str]):
|
||||
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
|
||||
if state["dirty"] or staged:
|
||||
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
for name, fn in hooks:
|
||||
event.remove(db, name, fn)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db,
|
||||
"level": "INFO",
|
||||
"until": None,
|
||||
"retention_days": 7,
|
||||
"session_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +83,7 @@ def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
|
||||
"level": "INFO",
|
||||
"until": None,
|
||||
"retention_days": 7,
|
||||
"session_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,3 +58,27 @@ def test_the_commit_check_counts_only_the_root_transaction(db):
|
||||
next(request)
|
||||
assert db.info["unfinished"] == ["POST /probe"]
|
||||
db.info["unfinished"].clear() # recorded as it should be — not this test's failure
|
||||
|
||||
|
||||
def test_a_request_that_raises_leaves_nothing_it_did_not_commit(db):
|
||||
"""Production closes a failing request's session, which rolls back whatever it flushed and did
|
||||
not commit; the shared test session must do the same, or a later read in the test sees a write
|
||||
production would have lost (E16: the tracker's issue link, kept only by an explicit commit in
|
||||
the handler's failure path). Driven directly, as a request would drive the dependency."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app.models import User
|
||||
|
||||
conn = HTTPConnection(
|
||||
{"type": "http", "method": "POST", "path": "/probe", "headers": [], "query_string": b""}
|
||||
)
|
||||
request = db.info["request"](conn)
|
||||
session = next(request)
|
||||
session.add(User(email="rolled-back-probe@example.com"))
|
||||
session.flush()
|
||||
with pytest.raises(RuntimeError):
|
||||
request.throw(RuntimeError("the handler failed"))
|
||||
assert db.scalar(select(User).where(User.email == "rolled-back-probe@example.com")) is None
|
||||
assert db.info["unfinished"] == [] # a failing request is rolled back, not reported
|
||||
|
||||
@@ -28,6 +28,8 @@ COMMITTING_HELPERS = {
|
||||
"commits nothing)",
|
||||
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
|
||||
"can already fetch",
|
||||
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
|
||||
"the handler is already unwinding and has no commit left to reach",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-4
@@ -34,6 +34,7 @@ import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
@@ -147,12 +148,18 @@ export default function App() {
|
||||
);
|
||||
|
||||
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or
|
||||
// the server saying it is off) refetches `me`, so Settings shows the base level again.
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
const diagnostics = meQuery.data?.diagnostics;
|
||||
const debugSession = meQuery.data?.debug_session;
|
||||
useEffect(() => {
|
||||
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() }));
|
||||
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
applyDiagnostics(
|
||||
diagnostics,
|
||||
debugSession,
|
||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||
);
|
||||
}, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
@@ -411,6 +418,8 @@ export default function App() {
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{meQuery.data && !meQuery.data.is_demo && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
<About
|
||||
|
||||
@@ -10,7 +10,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { CommandGroup } from "./ui/CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
// The rest of the module too: the recorder's actions reach `i18n/index`, which needs it.
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
@@ -140,4 +142,21 @@ describe("AppCommandPalette", () => {
|
||||
then?.();
|
||||
expect(requestSurprise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers the debug recorder by its state, and not to the demo account", () => {
|
||||
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
|
||||
mount();
|
||||
expect(recorder()).toEqual(["action:recorder:start"]);
|
||||
|
||||
cleanup();
|
||||
const until = new Date(Date.now() + 60_000).toISOString();
|
||||
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
|
||||
mount();
|
||||
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
|
||||
|
||||
cleanup();
|
||||
me = user({ is_demo: true });
|
||||
mount();
|
||||
expect(recorder()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
|
||||
import { Bug, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, Tv } from "lucide-react";
|
||||
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -10,6 +10,8 @@ import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { requestSurprise } from "../lib/homeIntent";
|
||||
import { openRecorderStart } from "../lib/debugRecorder";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { usePlaylistsActions } from "./playlistsContext";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
@@ -34,6 +36,8 @@ export default function AppCommandPalette() {
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { theme, setTheme } = usePrefs();
|
||||
const savedViews = useSavedViews();
|
||||
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
|
||||
const stopMutate = stopRecording.mutate;
|
||||
|
||||
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
|
||||
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
|
||||
@@ -147,6 +151,34 @@ export default function AppCommandPalette() {
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
// The debug recorder (E16) — not for the shared demo account, which cannot record. While
|
||||
// it runs, Stop and a step marker take Start's place: the palette is reachable inside a
|
||||
// player or a dialog where the pill's buttons are a long Tab away.
|
||||
...(me?.is_demo
|
||||
? []
|
||||
: recordingSession
|
||||
? [
|
||||
{
|
||||
id: "action:recorder:step",
|
||||
label: t("recorder.palette.step"),
|
||||
icon: <Flag className="w-4 h-4" />,
|
||||
run: () => markStep(""),
|
||||
},
|
||||
{
|
||||
id: "action:recorder:stop",
|
||||
label: t("recorder.palette.stop"),
|
||||
icon: <Square className="w-4 h-4" />,
|
||||
run: () => stopMutate(recordingSession.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "action:recorder:start",
|
||||
label: t("recorder.palette.start"),
|
||||
icon: <Bug className="w-4 h-4" />,
|
||||
run: openRecorderStart,
|
||||
},
|
||||
]),
|
||||
...SCHEMES.map((s) => ({
|
||||
id: `action:theme:${s.id}`,
|
||||
label: t("palette.theme", { name: s.name }),
|
||||
@@ -166,6 +198,9 @@ export default function AppCommandPalette() {
|
||||
savedViews,
|
||||
theme,
|
||||
setTheme,
|
||||
recordingSession,
|
||||
markStep,
|
||||
stopMutate,
|
||||
setPage,
|
||||
openChannel,
|
||||
setSelectedId,
|
||||
|
||||
@@ -134,6 +134,14 @@ export default function ConfigPanel() {
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
// Bug reports' "Check the connection" (E16): the repository answers, takes issues, is private.
|
||||
const testIssues = useMutation({
|
||||
mutationFn: () => api.testIssues(),
|
||||
onSuccess: (r) =>
|
||||
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }),
|
||||
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")),
|
||||
});
|
||||
|
||||
const plexLibs = (draft["plex_libraries"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
@@ -218,6 +226,22 @@ export default function ConfigPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "issues" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.issuesTestHint")}>
|
||||
<FocusSafeButton
|
||||
onClick={() => testIssues.mutate()}
|
||||
disabled={testIssues.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.issuesTest")}
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
|
||||
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
|
||||
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
|
||||
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
|
||||
// lives in a real query cache, as in the app.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { DebugSession, Me } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<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 { order, session, startDebugSession, stopDebugSession, hold, past } = vi.hoisted(() => {
|
||||
const past: { items: unknown[] } = { items: [] };
|
||||
// A Stop held open until released, when a test needs it in flight.
|
||||
const hold = { stop: null as Promise<void> | null };
|
||||
const order: string[] = [];
|
||||
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
|
||||
id: "0123456789abcdef",
|
||||
level: "DEBUG",
|
||||
started_at: new Date().toISOString(),
|
||||
until: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
ended_at: null,
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
});
|
||||
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
|
||||
Promise.resolve({ ok: true as const, session: session({ level }) }),
|
||||
);
|
||||
const stopDebugSession = vi.fn(async (id: string) => {
|
||||
order.push("stop");
|
||||
if (hold.stop) await hold.stop;
|
||||
return {
|
||||
ok: true as const,
|
||||
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
|
||||
};
|
||||
});
|
||||
return { order, session, startDebugSession, stopDebugSession, hold, past };
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
startDebugSession,
|
||||
stopDebugSession,
|
||||
debugSessions: () => Promise.resolve(past),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/diagnostics", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
|
||||
return {
|
||||
...actual,
|
||||
flushNow: () => {
|
||||
order.push("flush");
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
|
||||
import Modal from "./ui/Modal";
|
||||
import Toaster from "./ui/Toaster";
|
||||
import {
|
||||
closeComposer,
|
||||
openRecorderStart,
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
} from "../lib/debugRecorder";
|
||||
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { events } from "../lib/log";
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
hold.stop = null;
|
||||
sessionStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeComposer();
|
||||
setStepPrompt(null);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let composing: string | null = null;
|
||||
function ComposerProbe() {
|
||||
composing = useComposerSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount(me: Partial<Me>, extra: React.ReactNode = null) {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
...me,
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toaster />
|
||||
<DebugRecorder />
|
||||
<ComposerProbe />
|
||||
{extra}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
|
||||
|
||||
/** A dialog the test opens and closes while the pill is up: the pill moves into its toast host. */
|
||||
function DialogToggle() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
|
||||
{open && (
|
||||
<Modal title="dialog" onClose={() => setOpen(false)}>
|
||||
<p>inside</p>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("the recorder pill", () => {
|
||||
it("shows while a session records, under the toasts and outside their live region", () => {
|
||||
mount({ debug_session: session() });
|
||||
const p = pill()!;
|
||||
expect(p).not.toBeNull();
|
||||
expect(p.closest("[data-toast-region]")).not.toBeNull();
|
||||
expect(p.closest("[aria-live]")).toBeNull();
|
||||
});
|
||||
|
||||
it("is not there for a session that ended or ran past its cap", () => {
|
||||
mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) });
|
||||
expect(pill()).toBeNull();
|
||||
cleanup();
|
||||
mount({ debug_session: session({ ended_at: new Date().toISOString() }) });
|
||||
expect(pill()).toBeNull();
|
||||
});
|
||||
|
||||
it("Stop sends what is waiting first, then stops, then opens the composer", async () => {
|
||||
const qc = mount({ debug_session: session() });
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
|
||||
await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef"));
|
||||
expect(order).toEqual(["flush", "stop"]);
|
||||
await waitFor(() => expect(pill()).toBeNull());
|
||||
expect(qc.getQueryData<Me>(qk.me())?.debug_session).toBeNull();
|
||||
expect(composing).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("a step is one tagged event, numbered, and the focus comes back to Step", () => {
|
||||
mount({ debug_session: session() });
|
||||
const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" });
|
||||
fireEvent.click(step);
|
||||
const input = screen.getByLabelText("recorder.pill.stepPrompt:1");
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.change(input, { target: { value: "opened the player" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
const marked = events().filter((e) => e.event === "debug.step");
|
||||
expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" });
|
||||
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
|
||||
expect(document.activeElement).toBe(step);
|
||||
expect(step.textContent).toContain("· 1");
|
||||
});
|
||||
|
||||
it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal title="dialog" onClose={onClose}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
// The pill moved into the dialog's own toast host.
|
||||
expect(pill()!.closest('[role="dialog"]')).not.toBeNull();
|
||||
const marked = () => events().filter((e) => e.event === "debug.step").length;
|
||||
const before = marked();
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" });
|
||||
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(marked()).toBe(before);
|
||||
});
|
||||
|
||||
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal title="dialog" onClose={() => {}}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
|
||||
mark.focus();
|
||||
// The dialog's trap takes the Tab (jsdom has no layout, so it can only be seen taking it — the
|
||||
// browser would otherwise walk out of the dialog into the page behind its scrim).
|
||||
const unhandled = fireEvent.keyDown(mark, { key: "Tab" });
|
||||
expect(unhandled).toBe(false);
|
||||
});
|
||||
|
||||
it("the step prompt quiets the page's keys and leaves the Tab to the dialog it sits in", () => {
|
||||
mount({ debug_session: session() });
|
||||
expect(anyBlockingLayer()).toBe(false);
|
||||
// The pill docks inside whatever dialog is open; stand one in for it, under the prompt.
|
||||
const dialog = pushLayer();
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
|
||||
// The prompt's field has the keyboard, so `j` and the `g` chords stay off the page behind...
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
// ...while the dialog around it keeps its focus trap. The prompt cannot be that trap: its two
|
||||
// controls are two of the dialog's many, and taking it left the rest of them untrapped, with
|
||||
// Tab walking out into the page behind the scrim.
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false);
|
||||
// And the quiet is the prompt's own doing, not the stand-in's: it outlasts the dialog.
|
||||
removeLayer(dialog);
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
});
|
||||
|
||||
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
|
||||
mount({ debug_session: session() }, <DialogToggle />);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
fireEvent.change(screen.getByLabelText(/recorder.pill.stepPrompt/), {
|
||||
target: { value: "opened the player" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("toggle dialog"));
|
||||
expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); // moved: a new pill
|
||||
expect((screen.getByLabelText(/recorder.pill.stepPrompt/) as HTMLInputElement).value).toBe(
|
||||
"opened the player",
|
||||
);
|
||||
});
|
||||
|
||||
it("a Stop still running keeps its button busy after the pill moves", async () => {
|
||||
let release = () => {};
|
||||
hold.stop = new Promise((r) => (release = r));
|
||||
mount({ debug_session: session() }, <DialogToggle />);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
|
||||
await waitFor(() => expect(order).toContain("stop"));
|
||||
fireEvent.click(screen.getByText("toggle dialog"));
|
||||
const stop = within(pill()!).getByRole("button", { name: "recorder.pill.stop" });
|
||||
expect(stop.getAttribute("aria-busy")).toBe("true");
|
||||
release();
|
||||
await waitFor(() => expect(pill()).toBeNull());
|
||||
});
|
||||
|
||||
it("a Start or a Stop in another tab makes this one ask the server again", async () => {
|
||||
const qc = mount({ debug_session: session() });
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
const otherTab = new BroadcastChannel("siftlode.debugSession");
|
||||
otherTab.postMessage("changed");
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.me() }));
|
||||
otherTab.close();
|
||||
});
|
||||
|
||||
it("offers a screenshot only where the browser can capture a tab", () => {
|
||||
mount({ debug_session: session() });
|
||||
// jsdom has no Screen Capture API — like iOS Safari.
|
||||
expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull();
|
||||
});
|
||||
|
||||
it("at its cap it says so and opens the composer", () => {
|
||||
vi.useFakeTimers();
|
||||
mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) });
|
||||
act(() => void vi.advanceTimersByTime(5_000));
|
||||
expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull();
|
||||
expect(composing).toBe("0123456789abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the start dialog", () => {
|
||||
it("states the retention and the cap, and starts at the level chosen", async () => {
|
||||
const qc = mount({});
|
||||
act(() => openRecorderStart());
|
||||
expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull();
|
||||
expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" }));
|
||||
expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ }));
|
||||
await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE"));
|
||||
await waitFor(() => expect(pill()).not.toBeNull());
|
||||
expect(qc.getQueryData<Me>(qk.me())?.debug_session?.level).toBe("TRACE");
|
||||
expect(screen.queryByText("recorder.start.title")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the past sessions a user goes back to check", () => {
|
||||
function settings() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<RecorderSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("does not call a half-delivered report sent", async () => {
|
||||
// An issue that still owes its logs, or an edit that shows its screenshots, is not done. This
|
||||
// list is where a user checks back, and a green "Sent" here is how one gets forgotten.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa1",
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa2",
|
||||
issue_url: "https://git.example/o/r/issues/8",
|
||||
images_pending: true,
|
||||
}),
|
||||
session({ ...over, id: "aaaaaaaaaaaaaaa3", issue_url: "https://git.example/o/r/issues/9" }),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.partial")).toHaveLength(2);
|
||||
expect(screen.getAllByText("recorder.history.sent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says a send is still running rather than naming what it has got to so far", async () => {
|
||||
// This is the list a user opens TO CHECK on a send. Its two honest-looking words are both
|
||||
// wrong for a running one: "draft" before it has an issue, "half sent" after.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb1",
|
||||
sending: true,
|
||||
draft: { title: "t", body: "b", excluded: [], form: {} },
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb2",
|
||||
sending: true,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.sending")).toHaveLength(2);
|
||||
expect(screen.queryByText("recorder.history.draft")).toBeNull();
|
||||
expect(screen.queryByText("recorder.history.partial")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, Flag, Square } from "lucide-react";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
|
||||
import { canCaptureTab } from "../lib/captureTab";
|
||||
import {
|
||||
closeRecorderStart,
|
||||
onSessionChangeElsewhere,
|
||||
openComposer,
|
||||
openRecorderStart,
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
useRecorderStartOpen,
|
||||
useStepCount,
|
||||
useStepPrompt,
|
||||
} from "../lib/debugRecorder";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../lib/toastDock";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { inputCls } from "./ui/form";
|
||||
import IssueComposer from "./IssueComposer";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
//
|
||||
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
|
||||
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
|
||||
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
|
||||
// app shell, for a signed-in account other than the demo.
|
||||
|
||||
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
|
||||
function useElapsed(since: string): string {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
|
||||
}
|
||||
|
||||
export default function DebugRecorder() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const { session } = useDebugRecording();
|
||||
const startOpen = useRecorderStartOpen();
|
||||
const composing = useComposerSession();
|
||||
const id = session?.id;
|
||||
|
||||
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
|
||||
// Started or stopped in another tab: this one asks the server what is in force now.
|
||||
useEffect(
|
||||
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
|
||||
[qc],
|
||||
);
|
||||
|
||||
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
|
||||
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
|
||||
const until = session?.until;
|
||||
const minutes = me?.diagnostics?.session_minutes;
|
||||
useEffect(() => {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
const timer = window.setTimeout(() => {
|
||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
openComposer(id);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, wait);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [id, until, minutes, qc, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{startOpen && <StartDialog />}
|
||||
{composing && <IssueComposer id={composing} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StartDialog() {
|
||||
const { t } = useTranslation();
|
||||
const me = useMe();
|
||||
const { start } = useDebugRecording();
|
||||
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
|
||||
const days = me.diagnostics.retention_days;
|
||||
const minutes = me.diagnostics.session_minutes;
|
||||
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
|
||||
return (
|
||||
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("recorder.start.level")}
|
||||
options={levels.map((l) => ({
|
||||
value: l,
|
||||
label: t(`settings.diagnostics.levels.${l}`),
|
||||
}))}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
|
||||
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
||||
<p>{t(`recorder.start.what.${level}`)}</p>
|
||||
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
||||
<p className="text-muted">
|
||||
{t("recorder.start.kept", { count: days })}{" "}
|
||||
{t("recorder.start.cap", { count: minutes })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={closeRecorderStart}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={start.isPending}
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={() => start.mutate(level)}
|
||||
>
|
||||
{t("recorder.start.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pill: how long it has been recording, and the three things to do while it does. */
|
||||
function RecorderPill() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const stepOpen = useStepPrompt() !== null;
|
||||
const stepButton = useRef<HTMLButtonElement>(null);
|
||||
const steps = useStepCount(session?.id ?? "");
|
||||
if (!session) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("recorder.pill.label")}
|
||||
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
||||
hidden ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
|
||||
/>
|
||||
<Elapsed since={session.started_at} />
|
||||
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
|
||||
{t(`settings.diagnostics.levels.${session.level}`)}
|
||||
</span>
|
||||
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
|
||||
<Button
|
||||
ref={stepButton}
|
||||
variant="outline-accent"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Flag className="w-4 h-4" />}
|
||||
label={t("recorder.pill.step")}
|
||||
aria-expanded={stepOpen}
|
||||
onClick={() => setStepPrompt(stepOpen ? null : "")}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.step")}
|
||||
{steps ? ` · ${steps}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
{canCaptureTab() && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Camera className="w-4 h-4" />}
|
||||
label={t("recorder.pill.screenshot")}
|
||||
loading={shooting}
|
||||
onClick={() => void screenshot(setHidden)}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.screenshot")}
|
||||
{session.screenshots ? ` · ${session.screenshots}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Square className="w-3.5 h-3.5 fill-current" />}
|
||||
label={t("recorder.pill.stop")}
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
|
||||
</Button>
|
||||
{stepOpen && (
|
||||
<StepPrompt
|
||||
id={session.id}
|
||||
onDone={(label) => {
|
||||
if (label !== null) markStep(label);
|
||||
setStepPrompt(null);
|
||||
stepButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Elapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return (
|
||||
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
|
||||
// red dot already says what it is.
|
||||
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
||||
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
|
||||
<span className="sm:hidden">{time}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
|
||||
export function RecorderSettings() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<RecorderControl />
|
||||
<SessionHistory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecorderControl() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop, stopping } = useDebugRecording();
|
||||
if (!session)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={openRecorderStart}
|
||||
>
|
||||
{t("recorder.settings.start")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
|
||||
<SettingsElapsed since={session.started_at} />
|
||||
</span>
|
||||
<Button
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
{t("recorder.settings.stop")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsElapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
|
||||
}
|
||||
|
||||
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
|
||||
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const n = useStepCount(id) + 1;
|
||||
const label = useStepPrompt() ?? "";
|
||||
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
|
||||
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last. It
|
||||
// OWNS the keyboard while it is up (an autofocused field does), so the page's own keys stay quiet.
|
||||
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
|
||||
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
|
||||
// rest of the toast host. Claiming it here switched the dialog's off and left every control
|
||||
// OUTSIDE this little box untrapped (deep-review round 3).
|
||||
useLayer(true, () => onDone(null), { trapsTab: false });
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onDone(label);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
<input
|
||||
id="debug-step-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setStepPrompt(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={200}
|
||||
className={`${inputCls} w-full`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{t("recorder.pill.stepHint")}</span>
|
||||
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
|
||||
{t("recorder.pill.mark")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
|
||||
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
|
||||
* and every chip untoned, with the gate green (deep-review round 4). */
|
||||
type HistoryState =
|
||||
| "recorder.history.sending"
|
||||
| "recorder.history.draft"
|
||||
| "recorder.history.unwritten"
|
||||
| "recorder.history.partial"
|
||||
| "recorder.history.sent";
|
||||
|
||||
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
|
||||
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
|
||||
* green chip there is how a half-delivered report gets forgotten. */
|
||||
function historyState(s: DebugSession): HistoryState {
|
||||
// A send under way outranks every other word here: this is the list a user comes back to in
|
||||
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
|
||||
// already committed) would describe a send that STOPPED.
|
||||
if (s.sending) return "recorder.history.sending";
|
||||
if (!s.issue_url) return s.draft ? "recorder.history.draft" : "recorder.history.unwritten";
|
||||
return s.pending_files.length > 0 || s.images_pending
|
||||
? "recorder.history.partial"
|
||||
: "recorder.history.sent";
|
||||
}
|
||||
|
||||
/** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in
|
||||
* `historyState` is a compile error here rather than a chip that quietly loses its tone. */
|
||||
const chipTone = (s: DebugSession): string => {
|
||||
switch (historyState(s)) {
|
||||
case "recorder.history.sent":
|
||||
return "text-success";
|
||||
case "recorder.history.sending":
|
||||
return "text-accent";
|
||||
case "recorder.history.partial":
|
||||
return "text-warn";
|
||||
case "recorder.history.draft":
|
||||
case "recorder.history.unwritten":
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 ${chipTone(s)}`}>
|
||||
{t(historyState(s))}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
|
||||
{t("recorder.history.open")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const setLevel = vi.fn(
|
||||
level,
|
||||
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
|
||||
retention_days: 7,
|
||||
session_minutes: 60,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -61,7 +62,7 @@ function mount(me: Partial<Me>) {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7 },
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
...me,
|
||||
});
|
||||
render(
|
||||
@@ -131,7 +132,7 @@ describe("DiagnosticsSettings", () => {
|
||||
|
||||
it("going back to the base level needs no consent", async () => {
|
||||
const until = new Date(Date.now() + 3_600_000).toISOString();
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
|
||||
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
|
||||
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
|
||||
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
|
||||
@@ -169,7 +170,7 @@ describe("DiagnosticsSettings", () => {
|
||||
try {
|
||||
// Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering.
|
||||
const until = new Date(Date.now() + 3_600_000).toISOString();
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
|
||||
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
|
||||
const elsewhere = document.createElement("button");
|
||||
document.body.append(elsewhere);
|
||||
@@ -194,7 +195,7 @@ describe("DiagnosticsSettings", () => {
|
||||
|
||||
it("the ticking status line is not a live region", async () => {
|
||||
const until = new Date(Date.now() + 3_600_000).toISOString();
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
|
||||
const status = screen.getByTestId("diagnostics-status");
|
||||
expect(status.closest('[role="status"], [aria-live]')).toBeNull();
|
||||
});
|
||||
@@ -215,7 +216,7 @@ describe("DiagnosticsSettings", () => {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7 },
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
});
|
||||
await stale;
|
||||
expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE");
|
||||
@@ -224,7 +225,7 @@ describe("DiagnosticsSettings", () => {
|
||||
|
||||
it("a lapsed level shows as the base level", () => {
|
||||
const until = new Date(Date.now() - 60_000).toISOString();
|
||||
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
|
||||
mount({ diagnostics: { level: "TRACE", until, retention_days: 7, session_minutes: 60 } });
|
||||
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import InlineNotice from "./ui/InlineNotice";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import MyLogViewer from "./MyLogViewer";
|
||||
import { RecorderSettings } from "./DebugRecorder";
|
||||
|
||||
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
|
||||
|
||||
@@ -181,6 +182,12 @@ export default function DiagnosticsSettings() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{!me.is_demo && (
|
||||
<Section title={t("recorder.settings.title")}>
|
||||
<RecorderSettings />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={t("settings.myLog.title")}>
|
||||
<MyLogViewer />
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
// @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";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
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,
|
||||
failShots: false,
|
||||
sent: 0,
|
||||
previewed: [] as string[],
|
||||
// What happened, in order: a save is "save" when asked and "saved" when it answered.
|
||||
order: [] as string[],
|
||||
holdSend: null as Promise<void> | null,
|
||||
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
|
||||
failSendAfterCreate: 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: () =>
|
||||
api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }),
|
||||
saveDebugDraft: (_id: string, draft: DebugDraft) => {
|
||||
api.saved.push(draft);
|
||||
api.order.push("save");
|
||||
// Answers a task later, so a caller that does not wait for it is seen not waiting.
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
api.order.push("saved");
|
||||
resolve({ ok: true, session: { ...api.session, draft } });
|
||||
}, 0),
|
||||
);
|
||||
},
|
||||
addDebugScreenshot: (_id: string, image: Blob) => {
|
||||
api.uploaded.push(image);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
screenshot: { id: 1, mime: image.type, size: 4, created_at: "" },
|
||||
});
|
||||
},
|
||||
debugExport: () => {
|
||||
api.order.push("export");
|
||||
return Promise.resolve({ blob: new Blob(["zip"]), name: "siftlode-debug.zip" });
|
||||
},
|
||||
sendDebugIssue: async () => {
|
||||
api.order.push("send");
|
||||
if (api.holdSend) await api.holdSend;
|
||||
api.sent += 1;
|
||||
api.session = {
|
||||
...api.session,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: api.failSendAfterCreate ? ["server.log"] : [],
|
||||
// What the server does on the way through: a send that got there shows the images too,
|
||||
// so the banner (and the Retry in it) goes. Leaving this true made the retry's own
|
||||
// banner outlive its success, which is not a state the server can produce.
|
||||
images_pending: false,
|
||||
};
|
||||
if (api.failSendAfterCreate) throw new Error("issues_unreachable");
|
||||
return { ok: true as const, session: api.session };
|
||||
},
|
||||
previewDebugIssue: (_id: string, text: string) => {
|
||||
api.previewed.push(text);
|
||||
return Promise.resolve({ ok: true, html: "<h3>Steps</h3><script>alert(1)</script>" });
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
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;
|
||||
api.failShots = false;
|
||||
api.order = [];
|
||||
api.holdSend = null;
|
||||
api.failSendAfterCreate = false;
|
||||
api.sent = 0;
|
||||
api.previewed = [];
|
||||
tracker = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let tracker: { repo: string } | null = null;
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, issues: tracker });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<IssueComposer id={SID} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const lastSaved = () => api.saved[api.saved.length - 1]!;
|
||||
const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
|
||||
/** A button once it can be pressed: `loading` holds it while the draft's first save settles. */
|
||||
async function ready(name: string): Promise<HTMLButtonElement> {
|
||||
const button = (await screen.findByRole("button", { name })) as HTMLButtonElement;
|
||||
await waitFor(() => expect(button.disabled).toBe(false));
|
||||
return button;
|
||||
}
|
||||
|
||||
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("takes an image pasted while typing, anywhere in the composer", async () => {
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.attachments");
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(title(), { clipboardData: { files: [png] } });
|
||||
await waitFor(() => expect(api.uploaded).toHaveLength(1));
|
||||
});
|
||||
|
||||
it("saves an edit still waiting when it is closed", async () => {
|
||||
mount();
|
||||
await ready("recorder.composer.export");
|
||||
fireEvent.change(title(), { target: { value: "Typed and closed" } });
|
||||
cleanup(); // closed well inside the pause
|
||||
await waitFor(() => expect(api.saved).toHaveLength(1));
|
||||
expect(lastSaved().title).toBe("Typed and closed");
|
||||
});
|
||||
|
||||
it("does not call a newer edit saved", async () => {
|
||||
mount();
|
||||
await ready("recorder.composer.export");
|
||||
fireEvent.change(title(), { target: { value: "one" } });
|
||||
expect(
|
||||
await screen.findByText("recorder.composer.saved", {}, { timeout: 2000 }),
|
||||
).not.toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "two" } });
|
||||
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when the screenshots cannot be read, and still counts them from the session", async () => {
|
||||
api.failShots = true;
|
||||
api.session = baseSession({ screenshots: 2 });
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull();
|
||||
// Everything still works: the images are the one thing that could not be listed.
|
||||
expect(screen.queryByText("recorder.composer.attachments")).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
// Including the control standing right beside the failed list: more can still be added.
|
||||
expect(screen.queryByText("recorder.composer.pick")).not.toBeNull();
|
||||
// The draft is still saved — and says two screenshots, the server's own count, not none.
|
||||
fireEvent.change(title(), { target: { value: "x" } });
|
||||
await waitFor(() => expect(api.saved).toHaveLength(1), { timeout: 2000 });
|
||||
expect(lastSaved().body).toContain("recorder.issue.screenshots:2");
|
||||
});
|
||||
|
||||
it("says a send is under way instead of calling it half-failed, and holds the draft", async () => {
|
||||
// Every step of a send commits its progress, so a reload or a second tab reads exactly the
|
||||
// shape of a send that STOPPED. Only the server can tell the two apart — and it does.
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log", "screenshot-1.png"],
|
||||
sending: true,
|
||||
});
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
expect(screen.queryByText(/recorder.composer.partial/)).toBeNull();
|
||||
// No retry to press: it would only be refused while the send it invites is still running.
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "typed while it went out" } });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
expect(api.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("holds the draft through a first send, before there is an issue to point at", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// A send with no issue_url yet is what the read-only lock is for: an edit made mid-flight
|
||||
// would be saved over the very body the send is at that moment uploading, and the issue that
|
||||
// arrives would be neither draft. The half-sent case above is locked by its link instead.
|
||||
api.session = baseSession({ sending: true });
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "typed mid-flight" } });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
expect(api.saved).toHaveLength(0);
|
||||
// Nor can a screenshot join what is already on its way up.
|
||||
expect(screen.queryByText("recorder.composer.pick")).toBeNull();
|
||||
// And the button that would start a SECOND send is out — the server would only refuse it.
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "recorder.composer.send" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("announces the half-sent banner instead of only drawing it", async () => {
|
||||
// A screen-reader user hears the failure toast — "couldn't send the report" — and nothing else.
|
||||
// WHICH attachments are missing, and that a Retry now stands beside them, live only in this
|
||||
// banner, which is inserted already carrying its first message. A live region created together
|
||||
// with its content is unreliably announced (the rule ui/Toaster.tsx states and follows), so the
|
||||
// announcement has to come from a ROLE, which announces on insertion.
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log"],
|
||||
});
|
||||
mount();
|
||||
const said = await screen.findByText(/recorder\.composer\.partial/);
|
||||
const announced = said.closest('[role="status"]');
|
||||
expect(announced).not.toBeNull();
|
||||
// ...and the Retry is inside what gets read out, not left behind silently beside it.
|
||||
expect(announced!.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hands the focus on when a send half-fails, not only when it works", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The issue is created and an upload then dies, so the call FAILS with the link already
|
||||
// committed. The refetch brings the link back, `sent` flips, and "Create issue" — the control
|
||||
// holding the focus — unmounts under it. Without a hand-off the focus lands on <body> in the
|
||||
// middle of an open dialog: a screen reader loses its place and the Retry that just appeared
|
||||
// is several Tabs away, past the dialog's own ✕.
|
||||
api.failSendAfterCreate = true;
|
||||
mount();
|
||||
const send = (await ready("recorder.composer.send")) as HTMLButtonElement;
|
||||
send.focus();
|
||||
expect(document.activeElement).toBe(send);
|
||||
fireEvent.click(send);
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
const retry = await screen.findByRole("button", { name: "recorder.composer.retry" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(retry));
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
|
||||
});
|
||||
|
||||
it("still builds the zip while a send holds the draft, instead of saving into a refusal", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The zip is the only way out of a send that is stuck — a claim left by a dead request stands
|
||||
// for ten minutes. Its pre-save is pointless here (the draft is frozen, and the server would
|
||||
// 409 `issue_sending` and take the export down with it), and unnecessary: `GET /export` has no
|
||||
// send guard and builds from the saved draft, which is exactly what the send is uploading.
|
||||
api.session = baseSession({ sending: true });
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
click.mockImplementation(() => void api.order.push("click"));
|
||||
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = () => "blob:zip";
|
||||
URL.revokeObjectURL = () => {};
|
||||
try {
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
fireEvent.click(await ready("recorder.composer.export"));
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
expect(api.order).toEqual(["export", "click"]);
|
||||
expect(api.saved).toHaveLength(0);
|
||||
} finally {
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = real.make;
|
||||
URL.revokeObjectURL = real.revoke;
|
||||
}
|
||||
});
|
||||
|
||||
it("asks to show the images, not to send what is already there", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
images_pending: true,
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.partialImages");
|
||||
// Nothing is missing from the issue here — only its text has still to show what it carries.
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retryImages" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
// The button just pressed is gone with its banner: the focus goes to what takes its place,
|
||||
// not to <body> in the middle of an open dialog.
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("href")).toBe("https://git.example/o/r/issues/7"),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an image pasted into a dialog opened over it alone", async () => {
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.attachments");
|
||||
const above = document.createElement("div");
|
||||
above.setAttribute("aria-modal", "true");
|
||||
const field = document.createElement("input");
|
||||
above.appendChild(field);
|
||||
document.body.appendChild(above);
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(field, { clipboardData: { files: [png] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(api.uploaded).toHaveLength(0);
|
||||
above.remove();
|
||||
});
|
||||
|
||||
it("moves the focus to what stays when a control goes", async () => {
|
||||
mount();
|
||||
fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
|
||||
);
|
||||
const done = screen.getByRole("button", { name: "recorder.composer.reviewDone" });
|
||||
expect(document.activeElement).toBe(done);
|
||||
fireEvent.click(done);
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getAllByRole("button", { name: "recorder.composer.review" })[0],
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.removeStep:1" }));
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "recorder.composer.addStep" }),
|
||||
);
|
||||
});
|
||||
|
||||
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 asks for the zip, and hands it to the browser", async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
click.mockImplementation(() => void api.order.push("click"));
|
||||
const urls = { made: 0, revoked: 0 };
|
||||
// jsdom implements neither, so there is nothing to spy on: they are DEFINED here — and put
|
||||
// back afterwards, or every later test in this file would keep running against them.
|
||||
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = () => {
|
||||
urls.made += 1;
|
||||
return "blob:zip";
|
||||
};
|
||||
URL.revokeObjectURL = () => void (urls.revoked += 1);
|
||||
mount();
|
||||
fireEvent.click(await ready("recorder.composer.export"));
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
// A request, not a bare navigation: a refusal (the export's own pace) is shown like any other.
|
||||
expect(api.order).toEqual(["save", "saved", "export", "click"]);
|
||||
expect([urls.made, urls.revoked]).toEqual([1, 1]);
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = real.make;
|
||||
URL.revokeObjectURL = real.revoke;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("offers the tracker only where one is set up, and sends the saved draft", async () => {
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "recorder.composer.export" });
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
|
||||
cleanup();
|
||||
|
||||
tracker = { repo: "o/r" };
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
|
||||
fireEvent.click(await ready("recorder.composer.send"));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
// Saved, and answered, first: the issue is built from what was saved.
|
||||
expect(api.order).toEqual(["save", "saved", "send"]);
|
||||
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("holds the draft while it is being sent", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
let release = () => {};
|
||||
api.holdSend = new Promise((r) => (release = r));
|
||||
mount();
|
||||
fireEvent.click(await ready("recorder.composer.send"));
|
||||
await waitFor(() => expect(api.order).toContain("send"));
|
||||
// What is typed now would not reach the issue: the fields do not take it.
|
||||
expect(title().readOnly).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
|
||||
release();
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
});
|
||||
|
||||
it("finishes a send that stopped half-way, and says what is missing", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log"],
|
||||
});
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send
|
||||
});
|
||||
|
||||
it("previews through the tracker in a frame where no script runs", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
|
||||
const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<h3>Steps</h3>");
|
||||
expect(api.previewed[0]).toContain("1. Resume");
|
||||
});
|
||||
|
||||
it("previews the zip's own text where there is no tracker", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
|
||||
expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE");
|
||||
expect(api.previewed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,847 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } 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 { formatBytes } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useMe } from "../lib/useMe";
|
||||
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 SegmentedToggle from "./ui/SegmentedToggle";
|
||||
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 — an image pasted anywhere in it is taken). It saves as it goes, and what is still
|
||||
// waiting to be saved when it closes is saved then, 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;
|
||||
// Closed after a Stop, the focus has nowhere to go back to — the Stop button went with the pill —
|
||||
// and would be left on <body>. The page the user is on is the next best place. After the dialog's
|
||||
// own restore (a timeout: its cleanup runs after this one), and only when that had nothing.
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement === document.body)
|
||||
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
|
||||
}, 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
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 issueLink = useRef<HTMLAnchorElement>(null);
|
||||
const retryButton = useRef<HTMLButtonElement>(null);
|
||||
// The log whose review just closed: its Review button takes the focus back.
|
||||
const reviewed = useRef<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 ?? [];
|
||||
// How many the ISSUE will carry. From the list while it is loaded, else from the session itself
|
||||
// (the server's own count) — a failed read must not quietly become "no screenshots".
|
||||
const shotCount = shotsQ.data ? shots.length : session.screenshots;
|
||||
|
||||
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: shotCount },
|
||||
text,
|
||||
);
|
||||
|
||||
// Saves as it goes — after the typing pauses, not on every key. `edits` counts the edits, so a
|
||||
// save that finishes after a newer edit does not call that one saved.
|
||||
const edits = useRef(0);
|
||||
const [unsaved, setUnsaved] = useState(false);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const upTo = edits.current;
|
||||
const r = await api.saveDebugDraft(session.id, {
|
||||
title: form.title.trim(),
|
||||
body,
|
||||
excluded: form.excluded,
|
||||
form: form as unknown as Record<string, unknown>,
|
||||
});
|
||||
return { ...r, upTo };
|
||||
},
|
||||
meta: { failTitle: "recorder.composer.saveFailed" },
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
||||
if (r.upTo === edits.current) setUnsaved(false);
|
||||
},
|
||||
});
|
||||
const edit = (next: (f: DraftForm) => DraftForm) => {
|
||||
edits.current += 1;
|
||||
setUnsaved(true);
|
||||
setForm(next);
|
||||
};
|
||||
|
||||
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
|
||||
// A send that stopped half-way is finished by the same call, which does only what is missing.
|
||||
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
|
||||
const target = useMe()?.issues ?? null;
|
||||
const pending = session.pending_files;
|
||||
// A send is running — this tab's or another's. Its progress is committed step by step, so the
|
||||
// half-sent shape below is also what a LIVE send looks like; only the server can tell them apart.
|
||||
const sending = session.sending;
|
||||
const send = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sent) await save.mutateAsync();
|
||||
return api.sendDebugIssue(session.id);
|
||||
},
|
||||
meta: { failTitle: "recorder.composer.sendFailed" },
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
notify({ level: "success", message: t("recorder.composer.sentOk") });
|
||||
},
|
||||
// A failure half-way still created the issue: show what the server now has.
|
||||
onSettled: async () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
||||
await qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
|
||||
// Whichever way it went, the button that was just pressed is gone: a success unmounts the
|
||||
// Retry's banner, and a half-failure unmounts Create issue (the refetch above brings back the
|
||||
// issue_url it created before it died). Either leaves the focus on <body> inside an open
|
||||
// dialog, so hand it on — to whatever now stands where the pressed control was. AFTER the
|
||||
// refetch, not in onSuccess/onError: before it lands, neither target is rendered yet.
|
||||
window.setTimeout(() => (retryButton.current ?? issueLink.current)?.focus(), 0);
|
||||
},
|
||||
});
|
||||
const locked = sent || send.isPending || sending;
|
||||
|
||||
const saveRef = useRef(save.mutate);
|
||||
saveRef.current = save.mutate;
|
||||
useEffect(() => {
|
||||
if (locked || !unsaved) return;
|
||||
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [body, form.title, locked, unsaved]);
|
||||
// Closed before the typing paused: save what is still waiting, now.
|
||||
const flushRef = useRef(() => {});
|
||||
flushRef.current = () => {
|
||||
if (unsaved && !locked) save.mutate();
|
||||
};
|
||||
useEffect(() => () => flushRef.current(), []);
|
||||
|
||||
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
|
||||
// composer is taken — while typing the description is exactly when one is pasted.
|
||||
const addShot = useMutation({
|
||||
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: () => refreshShots(qc, session.id),
|
||||
});
|
||||
const addRef = useRef(addShot.mutate);
|
||||
addRef.current = addShot.mutate;
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (locked) return;
|
||||
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
|
||||
// And only OUR dialog's: something opened over the composer (the command palette) keeps its own.
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const dialog = e.target instanceof Element ? e.target.closest('[aria-modal="true"]') : null;
|
||||
if (dialog && !dialog.contains(root.current)) return;
|
||||
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
|
||||
if (!images.length) return;
|
||||
e.preventDefault();
|
||||
for (const image of images) addRef.current(image);
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
}, [locked]);
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
|
||||
// The export is built from the SAVED draft: save what is on screen first — except when the
|
||||
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
|
||||
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
|
||||
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
|
||||
// the one tracker-free way out of a stuck send used to fail at the last step (round 4).
|
||||
const exportZip = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!locked) await save.mutateAsync();
|
||||
return api.debugExport(session.id);
|
||||
},
|
||||
meta: { failTitle: "recorder.composer.exportFailed" },
|
||||
onSuccess: ({ blob, name }) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
});
|
||||
|
||||
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 ref={root} 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>
|
||||
{(sending || (sent && (pending.length > 0 || session.images_pending))) && (
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm ${
|
||||
sending ? "border-accent/40 bg-accent/10" : "border-warn/40 bg-warn/10"
|
||||
}`}
|
||||
// `role="status"` rather than `aria-live`: this box is mounted TOGETHER with its first
|
||||
// message (a half-failed send inserts it already populated), and a live region created
|
||||
// with its content is unreliably announced — the rule ui/Toaster.tsx states and follows.
|
||||
// A role announces on insertion, and its implied atomic re-read is right for one line.
|
||||
role="status"
|
||||
>
|
||||
<span>
|
||||
{sending
|
||||
? t("recorder.composer.sendingNow")
|
||||
: pending.length > 0
|
||||
? t("recorder.composer.partial", { files: pending.join(", ") })
|
||||
: t("recorder.composer.partialImages")}
|
||||
</span>
|
||||
{/* Nothing to retry while it is still going — the button would only be refused. */}
|
||||
{!sending && (
|
||||
<Button
|
||||
ref={retryButton}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={send.isPending}
|
||||
onClick={() => send.mutate()}
|
||||
>
|
||||
{t(pending.length > 0 ? "recorder.composer.retry" : "recorder.composer.retryImages")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
ref={issueLink}
|
||||
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 && (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
className="self-start"
|
||||
ariaLabel={t("recorder.composer.previewTitle")}
|
||||
options={[
|
||||
{ value: "edit", label: t("recorder.composer.edit") },
|
||||
{ value: "preview", label: t("recorder.composer.preview") },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
)}
|
||||
{mode === "preview" && !reviewing ? (
|
||||
<IssuePreview
|
||||
sessionId={session.id}
|
||||
text={`# ${form.title.trim() || "…"}\n\n${body}`}
|
||||
viaTracker={!!target}
|
||||
/>
|
||||
) : reviewing ? (
|
||||
<ReviewList
|
||||
rows={rows.filter((r) => sourceOf(r) === reviewing)}
|
||||
name={t(
|
||||
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
|
||||
)}
|
||||
excluded={excluded}
|
||||
readOnly={locked}
|
||||
onToggle={(rowId) =>
|
||||
edit((f) => ({
|
||||
...f,
|
||||
excluded: f.excluded.includes(rowId)
|
||||
? f.excluded.filter((x) => x !== rowId)
|
||||
: [...f.excluded, rowId],
|
||||
}))
|
||||
}
|
||||
onDone={() => {
|
||||
reviewed.current = reviewing;
|
||||
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={locked} />
|
||||
<Attachments
|
||||
session={session}
|
||||
shotsQ={shotsQ}
|
||||
counts={{
|
||||
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
|
||||
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
|
||||
}}
|
||||
readOnly={locked}
|
||||
adding={addShot.isPending}
|
||||
onAdd={(files) => {
|
||||
for (const file of files) if (isImage(file)) addShot.mutate(file);
|
||||
}}
|
||||
onReview={setReviewing}
|
||||
reviewed={reviewed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<span className="mr-auto flex flex-col text-xs text-muted">
|
||||
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
|
||||
<span aria-live="polite">
|
||||
{send.isPending
|
||||
? t("recorder.composer.sending")
|
||||
: save.isPending
|
||||
? t("recorder.composer.saving")
|
||||
: save.isSuccess && !unsaved
|
||||
? t("recorder.composer.saved")
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="ghost" onClick={closeComposer}>
|
||||
{t("recorder.composer.close")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={save.isPending || exportZip.isPending}
|
||||
disabled={send.isPending}
|
||||
onClick={() => exportZip.mutate()}
|
||||
>
|
||||
{t("recorder.composer.export")}
|
||||
</Button>
|
||||
{target && !sent && (
|
||||
// `sending` is a send ALREADY under way — from a reload, or from the other tab this one
|
||||
// is a copy of. The button would only earn an `issue_sending` refusal; the banner above
|
||||
// says why it is out.
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={send.isPending}
|
||||
disabled={sending}
|
||||
onClick={() => send.mutate()}
|
||||
>
|
||||
{t("recorder.composer.send")}
|
||||
</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();
|
||||
// A removed step's ✕ goes with it: the focus moves to "add a step", which stays.
|
||||
const addStep = useRef<HTMLButtonElement>(null);
|
||||
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) }));
|
||||
addStep.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
ref={addStep}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// The types the server keeps (backend debugsession.IMAGE_TYPES) — offered by the picker, and the
|
||||
// only ones a paste or a drop hands over.
|
||||
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
||||
const isImage = (file: File) => IMAGE_TYPES.includes(file.type);
|
||||
|
||||
/** What changes with a screenshot: its list, and the session's count — not the session's events,
|
||||
* which share the session's key prefix and are thousands of rows. */
|
||||
function refreshShots(qc: ReturnType<typeof useQueryClient>, id: string) {
|
||||
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(id) });
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSession(id), exact: true });
|
||||
}
|
||||
|
||||
function Attachments({
|
||||
session,
|
||||
shotsQ,
|
||||
counts,
|
||||
readOnly,
|
||||
adding,
|
||||
onAdd,
|
||||
onReview,
|
||||
reviewed,
|
||||
}: {
|
||||
session: DebugSession;
|
||||
shotsQ: { data?: { items: DebugScreenshot[] }; isError: boolean; refetch: () => unknown };
|
||||
counts: Record<Source, [kept: number, total: number]>;
|
||||
readOnly: boolean;
|
||||
adding: boolean;
|
||||
onAdd: (files: Iterable<File>) => void;
|
||||
onReview: (source: Source) => void;
|
||||
reviewed: MutableRefObject<Source | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const shots = shotsQ.data?.items ?? [];
|
||||
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
||||
const pick = useRef<HTMLInputElement>(null);
|
||||
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
|
||||
useEffect(() => {
|
||||
const source = reviewed.current;
|
||||
if (!source) return;
|
||||
reviewed.current = null;
|
||||
reviewButtons.current[source]?.focus();
|
||||
}, [reviewed]);
|
||||
const remove = useMutation({
|
||||
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
|
||||
meta: { failTitle: "recorder.composer.removeShotFailed" },
|
||||
onSuccess: () => refreshShots(qc, session.id),
|
||||
});
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
onAdd(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
|
||||
ref={(el) => {
|
||||
reviewButtons.current[source] = el;
|
||||
}}
|
||||
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">
|
||||
<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 />)}
|
||||
{shotsQ.isError && !shotsQ.data && (
|
||||
<StateMessage tone="error" compact onRetry={() => void shotsQ.refetch()}>
|
||||
{t("recorder.composer.shotsFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
{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">{formatBytes(shot.size)}</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);
|
||||
pick.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
aria-busy={adding}
|
||||
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 rounded font-semibold text-accent hover:underline focus-within:underline focus-within:ring-2 focus-within:ring-accent/60">
|
||||
{t("recorder.composer.pick")}
|
||||
<input
|
||||
ref={pick}
|
||||
type="file"
|
||||
accept={IMAGE_TYPES.join(",")}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
onAdd(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();
|
||||
// Opened from its Review button, which is gone now: the focus comes here.
|
||||
const done = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => done.current?.focus(), []);
|
||||
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 ref={done} 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The report as it will read. Through the tracker, its OWN rendering (Gitea's markdown and
|
||||
* sanitiser) — drawn in a sandboxed frame: no script runs in it, and nothing it holds reaches the
|
||||
* app's document. Without a tracker, the markdown itself: it is the zip's issue.md. */
|
||||
function IssuePreview({
|
||||
sessionId,
|
||||
text,
|
||||
viaTracker,
|
||||
}: {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
viaTracker: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({
|
||||
queryKey: qk.debugSessionPreview(sessionId, text),
|
||||
queryFn: () => api.previewDebugIssue(sessionId, text),
|
||||
enabled: viaTracker,
|
||||
});
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(240);
|
||||
if (!viaTracker)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
|
||||
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
if (q.isError && !q.data)
|
||||
return (
|
||||
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
|
||||
{t("recorder.composer.previewFailed")}
|
||||
</StateMessage>
|
||||
);
|
||||
if (!q.data) return <LoadingState />;
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
|
||||
// The frame is a document of its own: the app's text size (the root font, which the text-size
|
||||
// setting drives) is handed to it, and everything in it is sized off that.
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
|
||||
html{font-size:${css.fontSize}}
|
||||
body{margin:0;padding:.75rem .875rem;font:.875rem/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
|
||||
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:.25rem .5rem}
|
||||
code{font-family:ui-monospace,Consolas,monospace;font-size:.75rem}h1,h2,h3{line-height:1.25}
|
||||
</style></head><body>${q.data.html}</body></html>`;
|
||||
return (
|
||||
<iframe
|
||||
ref={frame}
|
||||
title={t("recorder.composer.previewTitle")}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
srcDoc={doc}
|
||||
onLoad={() => {
|
||||
const body = frame.current?.contentDocument?.body;
|
||||
if (body) setHeight(Math.min(body.scrollHeight + 4, window.innerHeight * 0.6));
|
||||
}}
|
||||
style={{ height }}
|
||||
className="w-full rounded-xl border border-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,28 +2,13 @@ import type { ReactNode } from "react";
|
||||
|
||||
import type { Level } from "../lib/log";
|
||||
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
|
||||
// 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.
|
||||
// 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({
|
||||
ts,
|
||||
level,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player paints an opaque, full-viewport layer over the whole app — and until deep-review
|
||||
// round 4 it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
|
||||
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
|
||||
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
|
||||
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
|
||||
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
||||
// too. Its own file because `layerStack` is module state and these tests push into it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
const behind: HTMLButtonElement[] = [];
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
while (behind.length) behind.pop()!.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
}
|
||||
|
||||
/** A control on the page the player is painted over — the thing focus used to escape onto. */
|
||||
function pageBehind() {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = "on the page behind";
|
||||
document.body.insertBefore(b, document.body.firstChild);
|
||||
behind.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
const player = () => document.querySelector<HTMLElement>(".fixed.z-overlay")!;
|
||||
|
||||
/** jsdom has no layout, so the trap's visibility filter (`offsetParent`) would see nothing tabbable
|
||||
* anywhere. Give every element one for the duration of a check — the house pattern (PlayerModal,
|
||||
* OnboardingWizard, ui/dialogFocus all do this). */
|
||||
function withLayout<T>(fn: () => T): T {
|
||||
const spy = vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("PlexPlayer · Tab stays inside the player", () => {
|
||||
it("pulls the focus back in when it is on the page behind", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
withLayout(() => press("Tab"));
|
||||
// Not left on a control under an opaque overlay: the trap takes it into the player.
|
||||
expect(document.activeElement).not.toBe(outside);
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps from the last of its own controls back to the first", async () => {
|
||||
await mount();
|
||||
const items = [
|
||||
...player().querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
];
|
||||
expect(items.length).toBeGreaterThan(1);
|
||||
const first = items[0]!;
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(document.activeElement).toBe(first);
|
||||
// ...and backwards, which is the direction that used to walk straight out behind the player.
|
||||
first.focus();
|
||||
withLayout(() => press("Tab", true));
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("keeps trapping while one of its OWN menus is open", async () => {
|
||||
await mount();
|
||||
// The settings and track menus are `useDismiss` layers. That hook owns Escape and the
|
||||
// outside-press and installs no Tab trap at all — but it used to register as a layer that
|
||||
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
|
||||
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
|
||||
// inside the player, so the player's trap is the one that must stay on.
|
||||
const gear = [...player().querySelectorAll<HTMLElement>("button")].find(
|
||||
(b) => b.getAttribute("aria-label") === "plex.player.settings",
|
||||
);
|
||||
expect(gear).toBeDefined();
|
||||
act(() => gear!.click());
|
||||
const items = [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
|
||||
// The menu really opened — the trap now has to span its controls too.
|
||||
expect(items.length).toBeGreaterThan(12);
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
});
|
||||
|
||||
it("leaves the Tab to a dialog opened above it, which traps for itself", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
const above = pushLayer(); // a Modal over the player — the download dialog, say
|
||||
try {
|
||||
withLayout(() => press("Tab"));
|
||||
// The player does not reach past it: the dialog above owns the keyboard and its own trap.
|
||||
expect(document.activeElement).toBe(outside);
|
||||
} finally {
|
||||
removeLayer(above);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,14 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import { trapTab } from "./ui/dialogFocus";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
anyBlockingLayerAbove,
|
||||
escapeHandled,
|
||||
} from "../lib/layerStack";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -896,6 +903,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
if (!isTypingTarget(e)) wake();
|
||||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||||
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
||||
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
||||
// PlayerModal, OnboardingWizard, Welcome); this one never did, and deep-review round 4 caught
|
||||
// it when the recorder's step prompt — which docks in this player's toast host — handed its
|
||||
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
|
||||
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
|
||||
// revealed the control band, so the trap cannot leave focus on a faded-out button.
|
||||
if (e.key === "Tab") {
|
||||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||
if (wrapRef.current) trapTab(e, wrapRef.current);
|
||||
return;
|
||||
}
|
||||
dispatch(e, "plexPlayer", {
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -36,13 +36,13 @@ domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
| area | components |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -50,6 +51,23 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
||||
const Docked = () => <button>dock</button>;
|
||||
const stop = dockUnderToasts(Docked);
|
||||
notify({ level: "info", message: "hello" });
|
||||
render(<Toaster />);
|
||||
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
|
||||
// The empty part of the region (beside and above a docked control) must not swallow the page's
|
||||
// clicks — and the two things that ARE there must still take them.
|
||||
expect(region.className).toContain("pointer-events-none");
|
||||
const card = screen.getByText("hello").closest(".toast-card")!;
|
||||
expect(card.className).toContain("pointer-events-auto");
|
||||
const dock = screen.getByText("dock").parentElement!;
|
||||
expect(dock.className).toContain("pointer-events-auto");
|
||||
expect(dock.closest("[data-toast-region]")).toBe(region);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("does not let a click on a toast reach the overlay's backdrop", () => {
|
||||
const backdrop = vi.fn();
|
||||
render(
|
||||
@@ -83,7 +101,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.firstElementChild!;
|
||||
const region = overlay.container.querySelector("[aria-live]")!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
||||
@@ -170,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
render(<Toaster overlay />);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
const host = hosts()[0]!;
|
||||
const host = hosts()[0]!.closest("[data-toast-region]")!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||||
import { createStore } from "../../lib/store";
|
||||
import { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function Toaster({
|
||||
}, [overlay, id, rank]);
|
||||
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
|
||||
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const Docked = useToastDock();
|
||||
const isTop = top === (overlay ? id : null);
|
||||
const toasts = isTop ? active : [];
|
||||
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts
|
||||
@@ -113,68 +115,90 @@ export default function Toaster({
|
||||
// uses for the same reason. Clicks between the cards go through to what is underneath.
|
||||
// Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right
|
||||
// of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19).
|
||||
//
|
||||
// Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top
|
||||
// host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
|
||||
<div
|
||||
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
|
||||
// lib/listNav: focus on a toast's Undo — or on the docked control — is not the reader leaving
|
||||
// the list the toast is about.
|
||||
data-toast-region=""
|
||||
aria-live={isTop && armed ? "polite" : "off"}
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100%-2rem)] ${
|
||||
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
|
||||
// Click-through in every host: the region is as wide as its (always present) list and as tall
|
||||
// as the docked pill, and without this the empty part beside and above the pill swallowed the
|
||||
// page's clicks. The cards and the dock opt back in.
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] pointer-events-none ${
|
||||
overlay ? "bottom-24" : "bottom-4"
|
||||
}`}
|
||||
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
|
||||
onClick={overlay ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||
const urgent = toast.level === "error" || toast.level === "fatal";
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||||
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||||
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||||
// Once armed, the region itself does the announcing and the role comes back off.
|
||||
role={
|
||||
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status"
|
||||
}
|
||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||
<div className="text-sm wrap-break-word">
|
||||
{toast.message}
|
||||
{toast.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{toast.repeat}
|
||||
</span>
|
||||
<div
|
||||
aria-live={isTop && armed ? "polite" : "off"}
|
||||
className="flex flex-col gap-2 w-80 max-w-full"
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||
const urgent = toast.level === "error" || toast.level === "fatal";
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||||
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||||
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||||
// Once armed, the region itself does the announcing and the role comes back off.
|
||||
role={
|
||||
inherited.has(toast.id)
|
||||
? undefined
|
||||
: urgent
|
||||
? "alert"
|
||||
: armed
|
||||
? undefined
|
||||
: "status"
|
||||
}
|
||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||
<div className="text-sm wrap-break-word">
|
||||
{toast.message}
|
||||
{toast.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{toast.repeat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
title={t("notifications.dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{toast.duration && (
|
||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
||||
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
title={t("notifications.dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{toast.duration && (
|
||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
||||
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isTop && Docked && (
|
||||
<div className="pointer-events-auto w-max max-w-full">
|
||||
<Docked />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from "react";
|
||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { captureTab } from "../lib/captureTab";
|
||||
import {
|
||||
announceSessionChange,
|
||||
closeRecorderStart,
|
||||
nextStep,
|
||||
openComposer,
|
||||
setStepPrompt,
|
||||
} from "../lib/debugRecorder";
|
||||
import { flushNow, liveSession } from "../lib/diagnostics";
|
||||
import { log } from "../lib/log";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { updateMeCache, useMe } from "../lib/useMe";
|
||||
|
||||
/** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings →
|
||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||
export function useDebugRecording() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const session = liveSession(me?.debug_session);
|
||||
const setSession = (next: DebugSession | null) => {
|
||||
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
||||
announceSessionChange();
|
||||
setStepPrompt(null); // a prompt left open belongs to the session that ended
|
||||
};
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
|
||||
meta: { failTitle: "recorder.start.failed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(r.session);
|
||||
closeRecorderStart();
|
||||
},
|
||||
});
|
||||
|
||||
const stop = useMutation({
|
||||
mutationKey: STOP_KEY,
|
||||
mutationFn: async (id: string) => {
|
||||
log.info("debug.stop", "recording stopped by the user");
|
||||
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
|
||||
// while the session still tags them: once it is closed the server no longer does.
|
||||
await flushNow();
|
||||
return api.stopDebugSession(id);
|
||||
},
|
||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(null);
|
||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
||||
openComposer(r.session.id);
|
||||
},
|
||||
});
|
||||
|
||||
const shoot = useMutation({
|
||||
mutationKey: SHOOT_KEY,
|
||||
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
|
||||
api.addDebugScreenshot(id, image),
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: (r) => {
|
||||
updateMeCache(qc, (prev) =>
|
||||
prev.debug_session
|
||||
? {
|
||||
...prev,
|
||||
debug_session: {
|
||||
...prev.debug_session,
|
||||
screenshots: prev.debug_session.screenshots + 1,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
||||
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
||||
notify({ level: "success", message: t("recorder.pill.shotSaved") });
|
||||
},
|
||||
});
|
||||
|
||||
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
|
||||
// to another toast host, and a Stop still running must keep its button busy there.
|
||||
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
|
||||
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
|
||||
|
||||
/** Mark the next step; `label` is what the user says they just did (may be empty). */
|
||||
const sessionId = session?.id;
|
||||
const markStep = useCallback(
|
||||
(label: string) => {
|
||||
if (!sessionId) return;
|
||||
const n = nextStep(sessionId);
|
||||
const text = label.trim();
|
||||
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
||||
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
|
||||
},
|
||||
[sessionId, t],
|
||||
);
|
||||
|
||||
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
||||
* frame. Nothing happens when the user cancels the browser's picker. */
|
||||
const screenshot = async (hide: (hidden: boolean) => void) => {
|
||||
if (!session) return;
|
||||
hide(true);
|
||||
let image: Blob | null = null;
|
||||
try {
|
||||
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
} finally {
|
||||
hide(false);
|
||||
}
|
||||
if (image) shoot.mutate({ id: session.id, image });
|
||||
};
|
||||
|
||||
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
|
||||
}
|
||||
|
||||
const STOP_KEY = ["debug-session", "stop"];
|
||||
const SHOOT_KEY = ["debug-session", "screenshot"];
|
||||
@@ -88,16 +88,26 @@ const DETAIL = "downloads.errors.fail.catalog.";
|
||||
|
||||
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
||||
const routeSources = (dir = "routes"): string[] =>
|
||||
const routeFiles = (dir = "routes"): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
||||
withFileTypes: true,
|
||||
}).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? routeSources(`${dir}/${e.name}`)
|
||||
? routeFiles(`${dir}/${e.name}`)
|
||||
: e.name.endsWith(".py")
|
||||
? [backend(`${dir}/${e.name}`)]
|
||||
? [`${dir}/${e.name}`]
|
||||
: [],
|
||||
);
|
||||
const routeSources = (): string[] => routeFiles().map(backend);
|
||||
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
|
||||
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
|
||||
const opaqueWhats = routeFiles()
|
||||
.filter((f) => !f.endsWith("/_common.py"))
|
||||
.flatMap((f) =>
|
||||
[...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map(
|
||||
(m) => `${f}: ${m[0]}`,
|
||||
),
|
||||
);
|
||||
const goneWhats = [
|
||||
...new Set(
|
||||
routeSources().flatMap((src) =>
|
||||
@@ -158,6 +168,7 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(opaqueWhats).toEqual([]);
|
||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
||||
expect(refusedReasons).toEqual(
|
||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
||||
|
||||
@@ -24,5 +24,6 @@
|
||||
"download_credential_set": "Download cookies set",
|
||||
"download_credential_delete": "Download cookies removed",
|
||||
"invite_delete": "Whitelist entry deleted",
|
||||
"invite_send": "Invitation sent"
|
||||
"invite_send": "Invitation sent",
|
||||
"issue_create": "Bug report sent"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"plex": "Plex",
|
||||
"tmdb": "TMDB",
|
||||
"audit": "Audit log",
|
||||
"diagnostics": "Diagnostics"
|
||||
"diagnostics": "Diagnostics",
|
||||
"issues": "Bug reports"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": {
|
||||
@@ -226,6 +227,18 @@
|
||||
"tmdb_language": {
|
||||
"label": "TMDB metadata language",
|
||||
"hint": "Language of the data fetched from TMDB (keywords, overviews), as a TMDB locale code such as en-US or hu-HU. Default en-US."
|
||||
},
|
||||
"issues_gitea_url": {
|
||||
"label": "Gitea address",
|
||||
"hint": "The Gitea instance, e.g. https://git.example.com. Left empty, reports can only be downloaded as a zip."
|
||||
},
|
||||
"issues_gitea_repo": {
|
||||
"label": "Repository",
|
||||
"hint": "As owner/repository. It must be PRIVATE: a report carries its reporter's log, and nothing is sent to a public one."
|
||||
},
|
||||
"issues_gitea_token": {
|
||||
"label": "Token",
|
||||
"hint": "A Gitea access token with write:issue and read:repository. Stored encrypted; write-only."
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@@ -292,5 +305,9 @@
|
||||
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
|
||||
"plexMovies": "Movies",
|
||||
"plexShows": "TV Shows",
|
||||
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test."
|
||||
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test.",
|
||||
"issuesTest": "Check the connection",
|
||||
"issuesTestHint": "Checks that the repository answers, is private and takes issues. Save your changes first.",
|
||||
"issuesTestOk": "Works: {{repo}} (private)",
|
||||
"issuesTestFailed": "The issue tracker check failed"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"recipient": "There's no user with that email address.",
|
||||
"job": "This job doesn't exist any more.",
|
||||
"download": "This download doesn't exist any more.",
|
||||
"debug_session": "This debug session doesn't exist any more.",
|
||||
"screenshot": "This screenshot doesn't exist any more.",
|
||||
"link": "This share link doesn't exist any more.",
|
||||
"notification": "This notification doesn't exist any more.",
|
||||
"account": "That account no longer exists.",
|
||||
@@ -205,6 +207,27 @@
|
||||
"wrong_password": "The current password is incorrect.",
|
||||
"wrong_share_password": "Wrong password.",
|
||||
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
|
||||
"youtube_read_scope": "Connect your YouTube account (read access) first."
|
||||
"youtube_read_scope": "Connect your YouTube account (read access) first.",
|
||||
"debug_session_conflict": "A recording just started in another tab. Reload the page.",
|
||||
"issue_already_sent": "This report was already sent; its draft can no longer change.",
|
||||
"issue_title_required": "Give the report a title before you send it.",
|
||||
"screenshot_already_sent": "The report was already sent; this session takes no more images.",
|
||||
"screenshot_too_many": "A session can have at most {{limit}} screenshots.",
|
||||
"screenshot_empty": "The image is empty.",
|
||||
"screenshot_not_an_image": "This is not a PNG, JPEG or WebP image.",
|
||||
"issues_not_configured": "No issue tracker is set up. Download the report as a zip instead.",
|
||||
"issues_unreachable": "The issue tracker isn't answering. Try again later.",
|
||||
"issues_bad_token": "The issue tracker refused the token, or it lacks the permission.",
|
||||
"issues_repo_not_found": "The configured issue repository was not found.",
|
||||
"issues_public_repo": "The issue repository is public. Because of the log, reports go only to a private one.",
|
||||
"issues_disabled": "The issue repository has its issue tracker switched off.",
|
||||
"issues_rejected": "The issue tracker refused the request.",
|
||||
"issues_rate_limited": "You can send at most {{limit}} reports an hour. Try again later.",
|
||||
"debug_session_rate_limited": "You can start at most {{limit}} new recordings an hour. Try again later.",
|
||||
"export_rate_limited": "Too many downloads in a row. Wait a minute.",
|
||||
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire.",
|
||||
"issue_sending": "The report is being sent right now. Wait for it to finish.",
|
||||
"issue_rate_limited": "At most {{limit}} send attempts an hour. Try again later.",
|
||||
"preview_rate_limited": "Too many previews in a row. Wait a minute."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"start": {
|
||||
"title": "Debug session",
|
||||
"intro": "Start it, go through whatever breaks, then stop it. You get a draft bug report with the log attached, and you can review everything before it is sent.",
|
||||
"level": "Detail",
|
||||
"whatTitle": "What it records",
|
||||
"what": {
|
||||
"DEBUG": "Page changes, requests and errors, which button, filter and player control you use, and the filters' values.",
|
||||
"TRACE": "Page changes, requests and errors, which button, filter and player control you use, the filters' values, and what you type into search."
|
||||
},
|
||||
"kept_one": "The server keeps it for {{count}} day.",
|
||||
"kept_other": "The server keeps it for {{count}} days.",
|
||||
"cap_one": "It runs for {{count}} minute at most, then stops on its own. When it stops, your previous log level is back.",
|
||||
"cap_other": "It runs for {{count}} minutes at most, then stops on its own. When it stops, your previous log level is back.",
|
||||
"confirm": "Start recording",
|
||||
"failed": "Couldn't start recording"
|
||||
},
|
||||
"pill": {
|
||||
"label": "Debug recording",
|
||||
"recording": "Recording · {{time}}",
|
||||
"step": "Step",
|
||||
"screenshot": "Screenshot",
|
||||
"stop": "Stop",
|
||||
"stepPrompt": "Step {{n}} — what did you just do? (optional)",
|
||||
"stepHint": "Enter: mark · Esc: cancel",
|
||||
"mark": "Mark",
|
||||
"stepMarked": "Step {{n}} marked",
|
||||
"shotSaved": "Screenshot saved",
|
||||
"shotFailed": "Couldn't save the screenshot",
|
||||
"stopFailed": "Couldn't stop recording",
|
||||
"stopped": "Recording stopped",
|
||||
"expired_one": "The recording reached its {{count}}-minute limit and stopped.",
|
||||
"expired_other": "The recording reached its {{count}}-minute limit and stopped."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Debug session",
|
||||
"hint": "When something breaks: start recording, go through it again, then stop. A draft bug report is made from the log.",
|
||||
"start": "Start recording",
|
||||
"recording": "Recording · {{time}}",
|
||||
"stop": "Stop"
|
||||
},
|
||||
"palette": {
|
||||
"start": "Start a debug recording",
|
||||
"step": "Mark a step in the recording",
|
||||
"stop": "Stop the debug recording"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"send": "Create issue",
|
||||
"sending": "Sending…",
|
||||
"sentOk": "Issue created",
|
||||
"sendFailed": "Couldn't send the report",
|
||||
"target": "To: {{repo}} · private",
|
||||
"partial": "The issue was created, but not every attachment went: {{files}}.",
|
||||
"retry": "Send the missing ones",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
"previewFailed": "Couldn't load the preview",
|
||||
"previewTitle": "Preview of the report",
|
||||
"previewLocal": "Without an issue tracker, this is the zip's issue.md.",
|
||||
"shotsFailed": "Couldn't load the screenshots",
|
||||
"removeShotFailed": "Couldn't remove the screenshot",
|
||||
"partialImages": "The issue was created, but its text does not show the screenshots yet.",
|
||||
"retryImages": "Show them in the issue",
|
||||
"sendingNow": "The report is being sent…",
|
||||
"exportFailed": "Couldn't build the zip"
|
||||
},
|
||||
"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",
|
||||
"partial": "Half sent",
|
||||
"sending": "Sending",
|
||||
"draft": "Draft",
|
||||
"unwritten": "Not written yet",
|
||||
"failed": "Couldn't load the sessions"
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,6 @@
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k eltávolítva",
|
||||
"invite_delete": "Whitelista-bejegyzés törölve",
|
||||
"invite_send": "Meghívó elküldve"
|
||||
"invite_send": "Meghívó elküldve",
|
||||
"issue_create": "Hibajegy elküldve"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"plex": "Plex",
|
||||
"tmdb": "TMDB",
|
||||
"audit": "Auditnapló",
|
||||
"diagnostics": "Diagnosztika"
|
||||
"diagnostics": "Diagnosztika",
|
||||
"issues": "Hibajegyek"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": {
|
||||
@@ -226,6 +227,18 @@
|
||||
"tmdb_language": {
|
||||
"label": "TMDB metaadat-nyelv",
|
||||
"hint": "A TMDB-ből lekért adatok (kulcsszavak, leírások) nyelve, TMDB nyelvkóddal, pl. en-US vagy hu-HU. Alapérték en-US."
|
||||
},
|
||||
"issues_gitea_url": {
|
||||
"label": "Gitea címe",
|
||||
"hint": "A Gitea-példány címe, pl. https://git.example.com. Üresen hagyva a hibajegyek csak zipként tölthetők le."
|
||||
},
|
||||
"issues_gitea_repo": {
|
||||
"label": "Tárhely",
|
||||
"hint": "tulajdonos/tárhely alakban. PRIVÁT tárhely legyen: a hibajegy a bejelentő naplóját viszi, és nyilvános tárhelyre nem küldünk."
|
||||
},
|
||||
"issues_gitea_token": {
|
||||
"label": "Token",
|
||||
"hint": "Gitea hozzáférési token write:issue és read:repository jogosultsággal. Titkosítva tárolva; csak írható."
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@@ -292,5 +305,9 @@
|
||||
"plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.",
|
||||
"plexMovies": "Filmek",
|
||||
"plexShows": "Sorozatok",
|
||||
"emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférés-kérés értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet."
|
||||
"emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférés-kérés értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet.",
|
||||
"issuesTest": "Kapcsolat ellenőrzése",
|
||||
"issuesTestHint": "Megnézi, hogy a tárhely elérhető, privát, és fogad hibajegyet. Előbb mentsd a módosításokat.",
|
||||
"issuesTestOk": "Rendben: {{repo}} (privát)",
|
||||
"issuesTestFailed": "A hibajegy-kezelő ellenőrzése nem sikerült"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
|
||||
"job": "Ez a feladat már nem létezik.",
|
||||
"download": "Ez a letöltés már nem létezik.",
|
||||
"debug_session": "Ez a hibakereső munkamenet már nem létezik.",
|
||||
"screenshot": "Ez a képernyőkép már nem létezik.",
|
||||
"link": "Ez a megosztási link már nem létezik.",
|
||||
"notification": "Ez az értesítés már nem létezik.",
|
||||
"account": "Ez a fiók már nem létezik.",
|
||||
@@ -205,6 +207,27 @@
|
||||
"wrong_password": "A jelenlegi jelszó helytelen.",
|
||||
"wrong_share_password": "Hibás jelszó.",
|
||||
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
|
||||
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)."
|
||||
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés).",
|
||||
"debug_session_conflict": "Egy másik lapon épp most indult rögzítés. Frissítsd az oldalt.",
|
||||
"issue_already_sent": "Ezt a hibajegyet már elküldted, a vázlata nem változtatható.",
|
||||
"issue_title_required": "Adj címet a hibajegynek, mielőtt elküldöd.",
|
||||
"screenshot_already_sent": "A hibajegy már elment, ehhez a munkamenethez nem adhatsz több képet.",
|
||||
"screenshot_too_many": "Egy munkamenethez legfeljebb {{limit}} képernyőkép tartozhat.",
|
||||
"screenshot_empty": "A kép üres.",
|
||||
"screenshot_not_an_image": "Ez nem PNG, JPEG vagy WebP kép.",
|
||||
"issues_not_configured": "Nincs beállítva hibajegy-kezelő. Töltsd le a hibajegyet zipként.",
|
||||
"issues_unreachable": "A hibajegy-kezelő nem válaszol. Próbáld újra később.",
|
||||
"issues_bad_token": "A hibajegy-kezelő nem fogadta el a tokent, vagy hiányzik a jogosultsága.",
|
||||
"issues_repo_not_found": "A beállított hibajegy-tárhely nem található.",
|
||||
"issues_public_repo": "A hibajegy-tárhely nyilvános. A napló miatt csak privát tárhelyre küldünk.",
|
||||
"issues_disabled": "A hibajegy-tárhelyen ki van kapcsolva a hibajegy-kezelés.",
|
||||
"issues_rejected": "A hibajegy-kezelő visszautasította a kérést.",
|
||||
"issues_rate_limited": "Óránként legfeljebb {{limit}} hibajegyet küldhetsz. Próbáld újra később.",
|
||||
"debug_session_rate_limited": "Óránként legfeljebb {{limit}} új rögzítést indíthatsz. Próbáld újra később.",
|
||||
"export_rate_limited": "Túl sok letöltés egymás után. Várj egy percet.",
|
||||
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak.",
|
||||
"issue_sending": "A hibajegy küldése épp folyamatban van. Várd meg, amíg befejeződik.",
|
||||
"issue_rate_limited": "Óránként legfeljebb {{limit}} küldési kísérlet. Próbáld újra később.",
|
||||
"preview_rate_limited": "Túl sok előnézet egymás után. Várj egy percet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"start": {
|
||||
"title": "Hibakereső munkamenet",
|
||||
"intro": "Indítsd el, csináld végig, ami elromlik, aztán állítsd le. A végén hibajegy-vázlatot kapsz a naplóval együtt, és elküldés előtt mindent átnézhetsz.",
|
||||
"level": "Részletesség",
|
||||
"whatTitle": "Mit rögzít",
|
||||
"what": {
|
||||
"DEBUG": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a szűrők értékeit.",
|
||||
"TRACE": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, a szűrők értékeit, és a keresőbe beírt szöveget is."
|
||||
},
|
||||
"kept_one": "A szerver {{count}} napig őrzi.",
|
||||
"kept_other": "A szerver {{count}} napig őrzi.",
|
||||
"cap_one": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.",
|
||||
"cap_other": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.",
|
||||
"confirm": "Rögzítés indítása",
|
||||
"failed": "Nem sikerült elindítani a rögzítést"
|
||||
},
|
||||
"pill": {
|
||||
"label": "Hibakereső rögzítés",
|
||||
"recording": "Rögzítés · {{time}}",
|
||||
"step": "Lépés",
|
||||
"screenshot": "Képernyőkép",
|
||||
"stop": "Leállítás",
|
||||
"stepPrompt": "{{n}}. lépés — mit csináltál most? (nem kötelező)",
|
||||
"stepHint": "Enter: megjelöl · Esc: mégse",
|
||||
"mark": "Megjelöl",
|
||||
"stepMarked": "{{n}}. lépés megjelölve",
|
||||
"shotSaved": "Képernyőkép elmentve",
|
||||
"shotFailed": "Nem sikerült elmenteni a képernyőképet",
|
||||
"stopFailed": "Nem sikerült leállítani a rögzítést",
|
||||
"stopped": "Rögzítés leállítva",
|
||||
"expired_one": "A rögzítés elérte a {{count}} perces határt, és leállt.",
|
||||
"expired_other": "A rögzítés elérte a {{count}} perces határt, és leállt."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Hibakereső munkamenet",
|
||||
"hint": "Ha valami elromlik: indítsd el a rögzítést, csináld végig újra, aztán állítsd le. A naplóból hibajegy-vázlat készül.",
|
||||
"start": "Rögzítés indítása",
|
||||
"recording": "Rögzítés folyamatban · {{time}}",
|
||||
"stop": "Leállítás"
|
||||
},
|
||||
"palette": {
|
||||
"start": "Hibakereső rögzítés indítása",
|
||||
"step": "Lépés megjelölése a rögzítésben",
|
||||
"stop": "Hibakereső rögzítés leállítása"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"send": "Hibajegy létrehozása",
|
||||
"sending": "Küldés…",
|
||||
"sentOk": "Hibajegy létrehozva",
|
||||
"sendFailed": "Nem sikerült elküldeni a hibajegyet",
|
||||
"target": "Cél: {{repo}} · privát",
|
||||
"partial": "A hibajegy létrejött, de nem ment el minden csatolmány: {{files}}.",
|
||||
"retry": "Hiányzók küldése",
|
||||
"edit": "Szerkesztés",
|
||||
"preview": "Előnézet",
|
||||
"previewFailed": "Nem sikerült betölteni az előnézetet",
|
||||
"previewTitle": "A hibajegy előnézete",
|
||||
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja.",
|
||||
"shotsFailed": "Nem sikerült betölteni a képernyőképeket",
|
||||
"removeShotFailed": "Nem sikerült törölni a képernyőképet",
|
||||
"partialImages": "A hibajegy létrejött, de a képernyőképek még nem látszanak a szövegében.",
|
||||
"retryImages": "Megjelenítés a hibajegyben",
|
||||
"sendingNow": "A hibajegy küldése folyamatban…",
|
||||
"exportFailed": "Nem sikerült elkészíteni a zipet"
|
||||
},
|
||||
"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",
|
||||
"partial": "Félig elküldve",
|
||||
"sending": "Küldés alatt",
|
||||
"draft": "Vázlat",
|
||||
"unwritten": "Megírásra vár",
|
||||
"failed": "Nem sikerült betölteni a munkameneteket"
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ import { messagingApi } from "./api/messaging";
|
||||
export type * from "./api/messaging";
|
||||
import { downloadsApi } from "./api/downloads";
|
||||
export type * from "./api/downloads";
|
||||
import { diagnosticsApi } from "./api/diagnostics";
|
||||
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
||||
export type * from "./api/diagnostics";
|
||||
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
||||
|
||||
@@ -90,4 +90,5 @@ export const api = {
|
||||
...messagingApi,
|
||||
...downloadsApi,
|
||||
...diagnosticsApi,
|
||||
...debugSessionsApi,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
||||
// app version, and preference save. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
import type { DiagnosticsState } from "./diagnostics";
|
||||
import type { DebugSession, DiagnosticsState } from "./diagnostics";
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
@@ -33,6 +33,10 @@ export interface Me {
|
||||
preferences: Record<string, unknown>;
|
||||
/** The consented diagnostics level in force and when it lapses (E22 S5). */
|
||||
diagnostics: DiagnosticsState;
|
||||
/** The debug session recording right now, if any (E16) — a reload resumes it. */
|
||||
debug_session: DebugSession | null;
|
||||
/** Where a bug report goes — the tracker's repository — or null: download only (E16 S4). */
|
||||
issues: { repo: string } | null;
|
||||
}
|
||||
|
||||
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user`
|
||||
|
||||
@@ -244,6 +244,9 @@ export const adminApi = {
|
||||
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
||||
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
||||
req("/api/admin/config/test-email", { method: "POST" }),
|
||||
/** The bug-report tracker (E16): answers, takes issues, and is private. */
|
||||
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
||||
req("/api/admin/config/test-issues", { method: "POST" }),
|
||||
// --- users & roles ---
|
||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||
|
||||
@@ -22,7 +22,9 @@ vi.mock("../activeAccount", () => ({
|
||||
pinActiveAccount: () => {},
|
||||
}));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
import {
|
||||
beacon,
|
||||
req,
|
||||
uploadReq,
|
||||
setUnauthorizedHandler,
|
||||
@@ -214,6 +216,40 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("req — a file, not a parsed answer", () => {
|
||||
it("hands back the response itself so the caller can read it as a blob", async () => {
|
||||
// A zip. Without the `raw` branch this falls into `r.json()`, throws on the first byte, and the
|
||||
// download dies as an opaque failure — which no lane would see, because the only consumer test
|
||||
// mocks the api method wholesale.
|
||||
const zip = new Response(new Blob(["PK"]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition": 'attachment; filename="siftlode-debug-260919.zip"',
|
||||
},
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(zip);
|
||||
|
||||
const r = await req<Response>("/api/debug-sessions/abc/export", {}, { raw: true });
|
||||
|
||||
expect(r).toBe(zip);
|
||||
expect(r.headers.get("content-disposition")).toContain("siftlode-debug-260919.zip");
|
||||
expect(await r.blob()).toBeInstanceOf(Blob); // the body is still there to be read
|
||||
});
|
||||
|
||||
it("still refuses before the body is touched, so a refusal is shown and not saved", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "refused", reason: "export_rate_limited" } }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
req<Response>("/api/debug-sessions/abc/export", {}, { raw: true, quiet: true }),
|
||||
).rejects.toMatchObject({ status: 429, detail: "errors.refused.export_rate_limited" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("req — what it has already put in front of the user", () => {
|
||||
// A caller that toasts a failed action on top of the dialog `req` already opened reported one fault
|
||||
// twice. `alreadyReported` is how `notifyActionError` tells the two apart.
|
||||
@@ -310,3 +346,32 @@ describe("req — what it has already put in front of the user", () => {
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// E16: while a debug session records, every request path names it — the server tags ITS lines of
|
||||
// that request with it. A path that forgot the header would leave holes in the evidence exactly
|
||||
// where the upload (XHR) or the unload beacon happened.
|
||||
describe("the debug session a tab records into", () => {
|
||||
const sentHeaders = () => {
|
||||
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
||||
return new Headers(init.headers as HeadersInit);
|
||||
};
|
||||
|
||||
it("rides every request path while it records, and none once it stops", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve(new Response("{}", { status: 200 })),
|
||||
);
|
||||
setDebugSession("0123456789abcdef");
|
||||
try {
|
||||
await req("/api/x");
|
||||
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
||||
beacon("/api/y", {});
|
||||
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
||||
void uploadReq("/u", form());
|
||||
expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef");
|
||||
} finally {
|
||||
setDebugSession(null);
|
||||
}
|
||||
await req("/api/x");
|
||||
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { newRequestId } from "../log";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
@@ -225,6 +225,8 @@ function reportFailure(err: HttpError, quiet: boolean): void {
|
||||
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
||||
export interface ReqConfig {
|
||||
idempotent?: boolean;
|
||||
// Return the `Response` itself instead of parsed JSON — for a file (see the return below).
|
||||
raw?: boolean;
|
||||
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
|
||||
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
|
||||
quiet?: boolean;
|
||||
@@ -253,24 +255,35 @@ export const setupHeaders = (token: string) => ({
|
||||
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
|
||||
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
|
||||
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
||||
// The debug session this tab is recording (E16): the server tags its own lines of the request with
|
||||
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
||||
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
||||
|
||||
export const getActiveAccount = activeAccountId;
|
||||
|
||||
/** Who this request is from — the tab's account and the debug session it records into — as the
|
||||
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
|
||||
* cannot quietly go out without them. */
|
||||
export function identityHeaders(): Record<string, string> {
|
||||
const active = getActiveAccount();
|
||||
const session = currentDebugSession();
|
||||
return {
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
|
||||
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
|
||||
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
|
||||
// would be lost.
|
||||
export function beacon(url: string, body: Record<string, unknown>): void {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void send(url, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
@@ -292,16 +305,15 @@ export async function req<T = unknown>(
|
||||
for (;;) {
|
||||
let r: Response;
|
||||
try {
|
||||
const active = getActiveAccount();
|
||||
r = await send(
|
||||
url,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Per-tab identity override (only setup calls pass their own headers, and those are
|
||||
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
|
||||
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||||
...identityHeaders(),
|
||||
},
|
||||
...opts,
|
||||
},
|
||||
@@ -348,6 +360,12 @@ export async function req<T = unknown>(
|
||||
reportFailure(err, !!cfg.quiet);
|
||||
throw err;
|
||||
}
|
||||
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. For a SMALL generated
|
||||
// file whose endpoint can refuse (a report, an export): sent as a plain `<a href>` it would
|
||||
// carry no refusal the app could show — the browser would simply save the error page. A large
|
||||
// stored file stays a plain navigation (the download centre, the Plex original), so the browser
|
||||
// streams it to disk instead of buffering gigabytes into the tab.
|
||||
if (cfg.raw) return r as T;
|
||||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||||
// every typed api.* method (T comes from its declared return type) is checked from here up.
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
@@ -372,8 +390,8 @@ export function uploadReq<T = unknown>(
|
||||
const requestId = newRequestId();
|
||||
const started = performance.now();
|
||||
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
|
||||
const active = getActiveAccount();
|
||||
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||
for (const [name, value] of Object.entries(identityHeaders()))
|
||||
xhr.setRequestHeader(name, value);
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The zip export is the one api method that reads a RAW response instead of a parsed answer, so it
|
||||
// carries contract of its own: the bytes, and the name the server picked. The composer's DOM test
|
||||
// mocks this method wholesale, so without this the blob and the filename parse ship untested — and
|
||||
// a broken header regex would silently save every report under the fallback name.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
|
||||
vi.mock("../notifications", () => ({
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
}));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
ACTIVE_ACCOUNT_KEY: "k",
|
||||
pinActiveAccount: () => {},
|
||||
}));
|
||||
|
||||
import { debugSessionsApi } from "./diagnostics";
|
||||
|
||||
const zip = (disposition?: string) =>
|
||||
new Response("PK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
...(disposition ? { "content-disposition": disposition } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("debugExport", () => {
|
||||
it("hands back the bytes and the name the server chose", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
zip('attachment; filename="siftlode-debug-20260919-1402-abc123.zip"'),
|
||||
);
|
||||
|
||||
const out = await debugSessionsApi.debugExport("0123456789abcdef");
|
||||
|
||||
expect(out.name).toBe("siftlode-debug-20260919-1402-abc123.zip");
|
||||
// The body is read as bytes, not parsed: `r.json()` on a zip would throw on its first one.
|
||||
expect(out.blob).toBeInstanceOf(Blob);
|
||||
expect(out.blob.size).toBe(4);
|
||||
});
|
||||
|
||||
it("names the file itself when the answer does not", async () => {
|
||||
// A proxy that strips the header must not leave the browser saving something called "export".
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(zip());
|
||||
|
||||
const out = await debugSessionsApi.debugExport("0123456789abcdef");
|
||||
|
||||
expect(out.name).toBe("siftlode-debug-0123456789abcdef.zip");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
|
||||
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
|
||||
import type { LogEvent } from "../log";
|
||||
import { beacon, req } from "./core";
|
||||
import type { ServerLogRow } from "./admin";
|
||||
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
|
||||
|
||||
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
|
||||
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
|
||||
@@ -12,6 +13,8 @@ export interface DiagnosticsState {
|
||||
until: string | null;
|
||||
/** How many days this instance keeps stored log lines — the consent text states it. */
|
||||
retention_days: number;
|
||||
/** How long a debug session records at most — the start dialog's consent text states it. */
|
||||
session_minutes: number;
|
||||
}
|
||||
|
||||
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
|
||||
@@ -33,3 +36,99 @@ export const diagnosticsApi = {
|
||||
/** The last batch of a closing tab (keepalive, fire-and-forget). */
|
||||
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
|
||||
};
|
||||
|
||||
// --- debug sessions (E16) ----------------------------------------------------------------------
|
||||
|
||||
/** The two levels a recording can run at — it always records more than the base. */
|
||||
export type DebugSessionLevel = "DEBUG" | "TRACE";
|
||||
|
||||
/** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is
|
||||
* over whether or not it was stopped. */
|
||||
export interface DebugSession {
|
||||
id: string;
|
||||
level: DebugSessionLevel;
|
||||
started_at: string;
|
||||
until: string;
|
||||
ended_at: string | null;
|
||||
ended_reason: "stopped" | "expired" | null;
|
||||
/** The issue it was sent as, once sent. */
|
||||
issue_url: string | null;
|
||||
/** Files a send that stopped half-way has still to deliver (retrying sends only these). */
|
||||
pending_files: string[];
|
||||
/** Whether the issue's body has still to be edited to show the screenshots it already carries. */
|
||||
images_pending: boolean;
|
||||
/** Whether a send is under way RIGHT NOW. Every step of a send commits its progress, so without
|
||||
* this a reload or a second tab reads a running send as a failed one. */
|
||||
sending: boolean;
|
||||
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 {
|
||||
id: number;
|
||||
mime: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SESSIONS = "/api/debug-sessions";
|
||||
|
||||
export const debugSessionsApi = {
|
||||
startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }),
|
||||
stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(`${SESSIONS}/${id}/stop`, { method: "POST" }),
|
||||
debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS),
|
||||
debugSession: (id: string): Promise<DebugSession> => req(`${SESSIONS}/${id}`),
|
||||
/** The rows an issue from this session would carry, browser and server, in time order. */
|
||||
debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> =>
|
||||
req(`${SESSIONS}/${id}/events`),
|
||||
debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots`),
|
||||
/** One image as the raw body — the server checks what it really is, whatever `type` says. */
|
||||
addDebugScreenshot: (
|
||||
id: string,
|
||||
image: Blob,
|
||||
): Promise<{ ok: true; screenshot: DebugScreenshot }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
|
||||
body: image,
|
||||
}),
|
||||
saveDebugDraft: (id: string, draft: DebugDraft): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(`${SESSIONS}/${id}/draft`, { method: "PUT", body: JSON.stringify(draft) }),
|
||||
/** The report as a zip, built from the SAVED draft — a request, not a navigation, so a refusal
|
||||
* (the export's own pace) reaches the user as a message instead of a saved error page. */
|
||||
debugExport: async (id: string): Promise<{ blob: Blob; name: string }> => {
|
||||
const r = await req<Response>(`${SESSIONS}/${id}/export`, {}, { raw: true });
|
||||
const disposition = r.headers.get("content-disposition") ?? "";
|
||||
const name = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `siftlode-debug-${id}.zip`;
|
||||
return { blob: await r.blob(), name };
|
||||
},
|
||||
/** Send the SAVED draft to the issue tracker, or finish a send that stopped half-way. */
|
||||
sendDebugIssue: (id: string): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(`${SESSIONS}/${id}/issue`, { method: "POST" }),
|
||||
/** The body as the tracker renders it (sanitised HTML — shown in a sandboxed frame only). */
|
||||
previewDebugIssue: (id: string, text: string): Promise<{ ok: true; html: string }> =>
|
||||
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
|
||||
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
|
||||
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
|
||||
* per-tab account's screenshot resolves to the session default and 404s. */
|
||||
debugScreenshotUrl: (id: string, shotId: number): string => {
|
||||
const a = getActiveAccount();
|
||||
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
|
||||
return a != null ? `${url}?account=${a}` : url;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
|
||||
["messagingApi", () => import("./messaging")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
] as const;
|
||||
|
||||
let api: Slice;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E16: one screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
|
||||
// a cancelled or refused picker is the user saying no (null, no error); anything else is an error;
|
||||
// a tab that was shared but gave no frame is an error too, not a silent nothing; and the capture is
|
||||
// stopped whatever happened — nothing keeps sharing the tab after one frame.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { captureTab } from "./captureTab";
|
||||
|
||||
function stubPicker(result: () => Promise<MediaStream>) {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getDisplayMedia: vi.fn(result) },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeStream() {
|
||||
const track = { stop: vi.fn() };
|
||||
return { stream: { getTracks: () => [track] } as unknown as MediaStream, track };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(navigator, "mediaDevices");
|
||||
});
|
||||
|
||||
describe("captureTab", () => {
|
||||
it.each(["NotAllowedError", "AbortError"])(
|
||||
"a %s from the picker is the user saying no",
|
||||
async (name) => {
|
||||
stubPicker(() => Promise.reject(new DOMException("no", name)));
|
||||
await expect(captureTab()).resolves.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("any other failure of the picker is an error", async () => {
|
||||
stubPicker(() => Promise.reject(new DOMException("broken", "NotReadableError")));
|
||||
await expect(captureTab()).rejects.toMatchObject({ name: "NotReadableError" });
|
||||
});
|
||||
|
||||
it("a shared tab that gives no frame is an error, and the capture is stopped", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { stream, track } = fakeStream();
|
||||
stubPicker(() => Promise.resolve(stream));
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
// jsdom's video never has a size: no frame arrives.
|
||||
const result = captureTab();
|
||||
const settled = expect(result).rejects.toMatchObject({ name: "NoFrameError" });
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await settled;
|
||||
expect(track.stop).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// One screenshot of this tab, for a debug session (E16 D5).
|
||||
//
|
||||
// Through the Screen Capture API with the current tab preferred: the browser asks every time (there
|
||||
// is no silent capture on the web, by design), the user can pick something else or cancel, and one
|
||||
// frame is taken before the track is stopped at once — nothing keeps recording.
|
||||
//
|
||||
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
|
||||
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
|
||||
// name Chrome gives a cancelled picker. A tab that WAS shared but gave no frame is an error, though:
|
||||
// the user said yes, and a silent nothing would read as a screenshot taken.
|
||||
//
|
||||
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
|
||||
// Firefox and Safari lack.
|
||||
|
||||
/** Whether this browser can capture a tab at all (iOS Safari cannot — the button is not shown). */
|
||||
export function canCaptureTab(): boolean {
|
||||
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
|
||||
}
|
||||
|
||||
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. Throws
|
||||
* `NoFrameError` when the shared tab gave no frame. */
|
||||
export async function captureTab(): Promise<Blob | null> {
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { displaySurface: "browser" },
|
||||
audio: false,
|
||||
// Chromium hints: offer THIS tab first, and nothing that would record the whole screen.
|
||||
preferCurrentTab: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "exclude",
|
||||
monitorTypeSurfaces: "exclude",
|
||||
} as DisplayMediaStreamOptions);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DOMException &&
|
||||
(err.name === "NotAllowedError" || err.name === "AbortError")
|
||||
)
|
||||
return null;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await firstFrame(stream);
|
||||
} finally {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class NoFrameError extends Error {
|
||||
constructor() {
|
||||
super("the shared tab gave no frame");
|
||||
this.name = "NoFrameError";
|
||||
}
|
||||
}
|
||||
|
||||
async function firstFrame(stream: MediaStream): Promise<Blob> {
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
|
||||
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
|
||||
if (!video.videoWidth) throw new NoFrameError();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
video.srcObject = null;
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!blob) throw new NoFrameError();
|
||||
return blob;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createStore } from "./store";
|
||||
|
||||
// The debug recorder's app-wide state (E16) — what several surfaces open or read, held outside any
|
||||
// one of them: Settings, the command palette and the recorder itself all open the start dialog, and
|
||||
// the composer opens for whichever session just ended.
|
||||
|
||||
const startOpen = createStore(false);
|
||||
/** Open the start dialog (Settings → Diagnosztika, the palette). */
|
||||
export const openRecorderStart = (): void => startOpen.set(true);
|
||||
export const closeRecorderStart = (): void => startOpen.set(false);
|
||||
export const useRecorderStartOpen = (): boolean => startOpen.use();
|
||||
|
||||
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
|
||||
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
|
||||
// one (a remount). The prompt must come along with what was typed.
|
||||
const stepPrompt = createStore<string | null>(null);
|
||||
/** The step prompt's text while it is open, else null. */
|
||||
export const useStepPrompt = (): string | null => stepPrompt.use();
|
||||
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
|
||||
|
||||
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
|
||||
// it only at their next `me` poll — and meanwhile record into a session that has ended.
|
||||
const CHANNEL = "siftlode.debugSession";
|
||||
let channel: BroadcastChannel | null | undefined;
|
||||
function tabs(): BroadcastChannel | null {
|
||||
if (channel === undefined)
|
||||
channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
|
||||
return channel;
|
||||
}
|
||||
/** Tell the other tabs the recording started or stopped. */
|
||||
export const announceSessionChange = (): void => tabs()?.postMessage("changed");
|
||||
/** Run `fn` when another tab started or stopped a recording. */
|
||||
export function onSessionChangeElsewhere(fn: () => void): () => void {
|
||||
const c = tabs();
|
||||
if (!c) return () => {};
|
||||
const handler = () => fn();
|
||||
c.addEventListener("message", handler);
|
||||
return () => c.removeEventListener("message", handler);
|
||||
}
|
||||
|
||||
const composing = createStore<string | null>(null);
|
||||
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
|
||||
export const openComposer = (id: string): void => composing.set(id);
|
||||
export const closeComposer = (): void => composing.set(null);
|
||||
export const useComposerSession = (): string | null => composing.use();
|
||||
|
||||
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
||||
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
||||
// the list the issue is drafted from contradict itself.
|
||||
const STEP_KEY = "siftlode.debugSteps";
|
||||
|
||||
function readSteps(): Record<string, number> {
|
||||
try {
|
||||
const raw = JSON.parse(sessionStorage.getItem(STEP_KEY) ?? "{}") as unknown;
|
||||
return raw && typeof raw === "object" ? (raw as Record<string, number>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createStore<Record<string, number>>(readSteps());
|
||||
|
||||
/** How many steps session `id` has marked in this tab. */
|
||||
export const useStepCount = (id: string): number => steps.use()[id] ?? 0;
|
||||
|
||||
/** Take the next step number for session `id`. */
|
||||
export function nextStep(id: string): number {
|
||||
const n = (steps.get()[id] ?? 0) + 1;
|
||||
// Only this session's count is kept: the others ended, and their numbers are in their events.
|
||||
steps.set({ [id]: n });
|
||||
try {
|
||||
sessionStorage.setItem(STEP_KEY, JSON.stringify(steps.get()));
|
||||
} catch {
|
||||
// Storage off: the count lives until the reload, which is all it is for.
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -34,7 +34,11 @@ beforeEach(() => {
|
||||
let current: typeof import("./diagnostics") | null = null;
|
||||
afterEach(() => {
|
||||
// A module copy left streaming would still hear `pagehide` in the next test.
|
||||
current?.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
current?.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
() => {},
|
||||
);
|
||||
current = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -51,6 +55,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
|
||||
level,
|
||||
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
|
||||
retention_days: 7,
|
||||
session_minutes: 60,
|
||||
});
|
||||
const codes = (batch: unknown[] | undefined) =>
|
||||
(batch ?? []).map((e) => (e as { event: string }).event);
|
||||
@@ -58,7 +63,11 @@ const codes = (batch: unknown[] | undefined) =>
|
||||
describe("diagnostics level", () => {
|
||||
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
() => {},
|
||||
);
|
||||
d.log.info("nav.view", "x");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(sent).toEqual([]);
|
||||
@@ -69,7 +78,7 @@ describe("diagnostics level", () => {
|
||||
it("a raised level streams only what happens after it, without its own request lines", async () => {
|
||||
const d = await fresh();
|
||||
d.log.info("before.consent", "not sent");
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
expect(d.enabled("DEBUG")).toBe(true);
|
||||
d.log.debug("ui.click", "click");
|
||||
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
|
||||
@@ -82,7 +91,7 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent.map((b) => b.length)).toEqual([100]);
|
||||
@@ -94,9 +103,13 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("TRACE"), () => {});
|
||||
d.applyDiagnostics(raised("TRACE"), null, () => {});
|
||||
d.log.trace("search.typed", "lofi");
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
() => {},
|
||||
);
|
||||
d.log.info("nav.view", "after");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(sent).toEqual([]);
|
||||
@@ -106,7 +119,7 @@ describe("diagnostics level", () => {
|
||||
it("lapses on its own timer and tells the app to refetch", async () => {
|
||||
const d = await fresh();
|
||||
const lapsed = vi.fn();
|
||||
d.applyDiagnostics(raised("DEBUG", 1), lapsed);
|
||||
d.applyDiagnostics(raised("DEBUG", 1), null, lapsed);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
|
||||
expect(lapsed).toHaveBeenCalledOnce();
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
@@ -118,14 +131,14 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("an already-expired level is the base level", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG", -1), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG", -1), null, () => {});
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
});
|
||||
|
||||
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
|
||||
const d = await fresh();
|
||||
const lapsed = vi.fn();
|
||||
d.applyDiagnostics(raised("DEBUG"), lapsed);
|
||||
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
|
||||
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
|
||||
d.log.debug("ui.click", "kept for later");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
@@ -138,7 +151,7 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("after a 429 nothing is sent until the back-off is over, however full the queue", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
|
||||
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -153,7 +166,7 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("past the queue cap the oldest events go, a refused batch included", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
|
||||
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -173,14 +186,18 @@ describe("diagnostics level", () => {
|
||||
const d = await fresh();
|
||||
let refuse: (err: unknown) => void = () => {};
|
||||
answer = () => new Promise((_, reject) => (refuse = reject));
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
d.log.debug("ui.click", "under the first consent");
|
||||
await vi.advanceTimersByTimeAsync(5_000); // in flight
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
() => {},
|
||||
);
|
||||
refuse(new d.HttpError(429, "slow down"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
answer = () => Promise.resolve({ ok: true, stored: 0 });
|
||||
d.applyDiagnostics(raised("TRACE"), () => {});
|
||||
d.applyDiagnostics(raised("TRACE"), null, () => {});
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const later = sent
|
||||
.slice(1)
|
||||
@@ -198,12 +215,16 @@ describe("diagnostics level", () => {
|
||||
const d = await fresh();
|
||||
let refuse: (err: unknown) => void = () => {};
|
||||
answer = () => new Promise((_, reject) => (refuse = reject));
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
d.log.debug("ui.click", "under the first consent");
|
||||
await vi.advanceTimersByTimeAsync(5_000); // in flight
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
() => {},
|
||||
);
|
||||
answer = () => Promise.resolve({ ok: true, stored: 0 });
|
||||
d.applyDiagnostics(raised("TRACE"), () => {});
|
||||
d.applyDiagnostics(raised("TRACE"), null, () => {});
|
||||
refuse(new d.HttpError(429, "slow down")); // only now does the old consent's batch come back
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const later = sent
|
||||
@@ -219,12 +240,16 @@ describe("diagnostics level", () => {
|
||||
const lapsed = vi.fn();
|
||||
let refuse: (err: unknown) => void = () => {};
|
||||
answer = () => new Promise((_, reject) => (refuse = reject));
|
||||
d.applyDiagnostics(raised("DEBUG"), lapsed);
|
||||
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
|
||||
d.log.debug("ui.click", "under the first consent");
|
||||
await vi.advanceTimersByTimeAsync(5_000); // in flight
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, lapsed);
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
lapsed,
|
||||
);
|
||||
answer = () => Promise.resolve({ ok: true, stored: 0 });
|
||||
d.applyDiagnostics(raised("TRACE"), lapsed);
|
||||
d.applyDiagnostics(raised("TRACE"), null, lapsed);
|
||||
refuse(new d.HttpError(409, "off"));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(lapsed).not.toHaveBeenCalled();
|
||||
@@ -238,10 +263,14 @@ describe("diagnostics level", () => {
|
||||
const lapsed = vi.fn();
|
||||
let refuse: (err: unknown) => void = () => {};
|
||||
answer = () => new Promise((_, reject) => (refuse = reject));
|
||||
d.applyDiagnostics(raised("DEBUG"), lapsed);
|
||||
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
|
||||
d.log.debug("ui.click", "under the consent");
|
||||
await vi.advanceTimersByTimeAsync(5_000); // in flight
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, lapsed);
|
||||
d.applyDiagnostics(
|
||||
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
null,
|
||||
lapsed,
|
||||
);
|
||||
refuse(new d.HttpError(409, "off"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(lapsed).not.toHaveBeenCalled();
|
||||
@@ -255,7 +284,7 @@ describe("diagnostics level", () => {
|
||||
const d = await fresh();
|
||||
let refuse: (err: unknown) => void = () => {};
|
||||
answer = () => new Promise((_, reject) => (refuse = reject));
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `old ${i}`);
|
||||
await vi.advanceTimersByTimeAsync(0); // the 100 oldest are in flight, the queue is empty
|
||||
for (let i = 0; i < 1_000; i++) d.log.debug("ui.click", `new ${i}`);
|
||||
@@ -273,7 +302,7 @@ describe("diagnostics level", () => {
|
||||
|
||||
it("a closing tab sends what is still waiting with a keepalive request", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
d.log.debug("ui.click", "last click");
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E16: a debug session applied to the tab. It raises the level for its own run (starting it was the
|
||||
// consent), never lowers a raised one, tags every event it keeps, and its last seconds reach the
|
||||
// server before Stop. Its own file: `lib/diagnostics` and `lib/log` keep module-level state, so every
|
||||
// test imports fresh copies of both, as diagnostics.dom.test does.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sent: { event: string; debug_session_id?: string }[][] = [];
|
||||
|
||||
vi.mock("./api", async () => {
|
||||
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
|
||||
return {
|
||||
HttpError: core.HttpError,
|
||||
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
|
||||
api: {
|
||||
sendLogEvents: (events: { event: string }[]) => {
|
||||
sent.push(events);
|
||||
return Promise.resolve({ ok: true, stored: events.length });
|
||||
},
|
||||
sendLogEventsOnUnload: () => {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const BASE = { level: "INFO" as const, until: null, retention_days: 7, session_minutes: 60 };
|
||||
const raised = (level: "DEBUG" | "TRACE") => ({
|
||||
...BASE,
|
||||
level,
|
||||
until: new Date(Date.now() + 24 * 3_600_000).toISOString(),
|
||||
});
|
||||
const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
|
||||
id: "0123456789abcdef",
|
||||
level,
|
||||
started_at: new Date().toISOString(),
|
||||
until: new Date(Date.now() + minutes * 60_000).toISOString(),
|
||||
ended_at: null,
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
let current: typeof import("./diagnostics") | null = null;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
sent.length = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
current?.applyDiagnostics(BASE, null, () => {});
|
||||
current = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function fresh() {
|
||||
const diagnostics = await import("./diagnostics");
|
||||
current = diagnostics;
|
||||
return { ...diagnostics, ...(await import("./log")) };
|
||||
}
|
||||
|
||||
const flat = () => sent.flat();
|
||||
|
||||
describe("a debug session applied to the tab", () => {
|
||||
it("raises the base level to its own, and tags and streams what it keeps", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
|
||||
expect(d.enabled("DEBUG")).toBe(true);
|
||||
expect(d.currentDebugSession()).toBe("0123456789abcdef");
|
||||
d.log.debug("ui.click", "click");
|
||||
expect(d.events().slice(-1)[0]?.debug_session_id).toBe("0123456789abcdef");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
const click = flat().find((e) => e.event === "ui.click");
|
||||
expect(click?.debug_session_id).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("never lowers a level the user raised further", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("TRACE"), recording("DEBUG"), () => {});
|
||||
expect(d.enabled("TRACE")).toBe(true);
|
||||
expect(d.currentDebugSession()).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("an ended session records nothing and tags nothing", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(
|
||||
BASE,
|
||||
recording("DEBUG", 60, { ended_at: new Date().toISOString() }),
|
||||
() => {},
|
||||
);
|
||||
expect(d.currentDebugSession()).toBeNull();
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
});
|
||||
|
||||
it("at its cap it lapses back to the user's own level, untagged", async () => {
|
||||
const d = await fresh();
|
||||
const lapsed = vi.fn();
|
||||
d.applyDiagnostics(raised("DEBUG"), recording("TRACE", 1), lapsed);
|
||||
expect(d.enabled("TRACE")).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(d.currentDebugSession()).toBeNull();
|
||||
// The user's own Részletes is still in force — the session's end is not the consent's.
|
||||
expect(d.enabled("DEBUG")).toBe(true);
|
||||
expect(d.enabled("TRACE")).toBe(false);
|
||||
expect(lapsed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("at its cap the tag goes even when the user's own level outlasts the session", async () => {
|
||||
// Own Teljes for 24 h, a Részletes session for 1 min: the level never changes, so only the
|
||||
// session's own end can take its tag off the events that follow.
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("TRACE"), recording("DEBUG", 1), () => {});
|
||||
expect(d.currentDebugSession()).toBe("0123456789abcdef");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(d.currentDebugSession()).toBeNull();
|
||||
expect(d.enabled("TRACE")).toBe(true);
|
||||
d.log.debug("ui.after", "after");
|
||||
expect(d.events().slice(-1)[0]?.debug_session_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starting a recording on a raised level drops nothing already waiting", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), null, () => {});
|
||||
d.log.debug("ui.before", "before");
|
||||
d.applyDiagnostics(raised("DEBUG"), recording("TRACE"), () => {});
|
||||
d.log.debug("ui.after", "after");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
const codes = flat().map((e) => e.event);
|
||||
expect(codes).toEqual(expect.arrayContaining(["ui.before", "ui.after"]));
|
||||
});
|
||||
|
||||
it("its last seconds are sent before its cap, while the server still tags them", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(BASE, recording("DEBUG", 1), () => {});
|
||||
await vi.advanceTimersByTimeAsync(57_000); // the regular flushes ran; the next is past the cap
|
||||
d.log.info("debug.step", "the last step");
|
||||
await vi.advanceTimersByTimeAsync(2_000); // 59 s: the cap is still a second away
|
||||
const step = flat().find((e) => e.event === "debug.step");
|
||||
expect(step?.debug_session_id).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("flushNow sends what is waiting at once — before Stop closes the session", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
|
||||
d.log.info("debug.step", "the last step");
|
||||
expect(flat().some((e) => e.event === "debug.step")).toBe(false);
|
||||
await d.flushNow();
|
||||
const step = flat().find((e) => e.event === "debug.step");
|
||||
expect(step?.debug_session_id).toBe("0123456789abcdef");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,29 @@
|
||||
// The user's consented diagnostics level, applied to this tab (E22 S5).
|
||||
// The user's consented diagnostics level, applied to this tab (E22 S5) — and the debug session it
|
||||
// may be recording (E16).
|
||||
//
|
||||
// `applyDiagnostics(me.diagnostics)` runs whenever `me` answers. It sets the client log's level, and
|
||||
// while that level is raised (Részletes / Teljes) it streams the log to the server:
|
||||
// `applyDiagnostics(me.diagnostics, me.debug_session)` runs whenever `me` answers. It sets the client
|
||||
// log's level, and while that level is raised (Részletes / Teljes) it streams the log to the server:
|
||||
// * only events kept AFTER the level was raised — the consent is for what happens from then on;
|
||||
// * in batches, every FLUSH_MS or once BATCH_MAX are waiting, and the rest with a keepalive
|
||||
// request when the tab closes;
|
||||
// * never the lines about its own requests, or each batch would carry the previous one's;
|
||||
// * and it stops at once when the level is lowered, when it lapses (a timer, not the next `me`
|
||||
// refetch), or when the server answers that it is off.
|
||||
// A recording raises the level to its own for as long as it runs (starting it was the consent), and
|
||||
// never lowers it: a user already at Teljes records at Teljes. Every event kept while it runs carries
|
||||
// its id, and so does every request (`api/core`), which is how the server tags its own lines.
|
||||
// A lost batch is dropped, not retried in a loop: this is a diagnostic stream, not a ledger, and a
|
||||
// retry storm against a struggling server is the last thing a diagnosis needs. The one exception is
|
||||
// being told to slow down (429): that batch goes back to the front of the queue, and nothing is sent
|
||||
// for BACKOFF_MS — not even when a full batch is waiting, or every new event would send one.
|
||||
import { api, DIAGNOSTICS_EVENTS_PATH, HttpError, type DiagnosticsState } from "./api";
|
||||
import { log, setLevel, subscribe, type LogEvent } from "./log";
|
||||
import {
|
||||
api,
|
||||
DIAGNOSTICS_EVENTS_PATH,
|
||||
HttpError,
|
||||
type DebugSession,
|
||||
type DiagnosticsState,
|
||||
} from "./api";
|
||||
import { log, setDebugSession, setLevel, subscribe, type LogEvent } from "./log";
|
||||
|
||||
const FLUSH_MS = 5_000;
|
||||
/** Per request. The server takes up to 200; staying under it leaves room for a burst. */
|
||||
@@ -29,24 +39,52 @@ export function levelInForce(state: DiagnosticsState | null | undefined, now = D
|
||||
return Date.parse(state.until) > now ? state.level : "INFO";
|
||||
}
|
||||
|
||||
/** The session if it is still recording: not ended, and before its cap. */
|
||||
export function liveSession(
|
||||
session: DebugSession | null | undefined,
|
||||
now = Date.now(),
|
||||
): DebugSession | null {
|
||||
if (!session || session.ended_at || Date.parse(session.until) <= now) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
|
||||
|
||||
let applied: string | null = null;
|
||||
let lapseTimer: number | null = null;
|
||||
// A recording's last seconds are sent BEFORE its cap: at the cap the server stops tagging (or, at the
|
||||
// base level, taking) them, and the regular flush could be up to FLUSH_MS behind.
|
||||
const CAP_FLUSH_LEAD_MS = 1_500;
|
||||
let capFlushTimer: number | null = null;
|
||||
let onLapsed: (() => void) | null = null;
|
||||
let lastState: DiagnosticsState | null | undefined = null;
|
||||
let lastSession: DebugSession | null | undefined = null;
|
||||
|
||||
/** Apply `state` to this tab. `lapsed` is called when a raised level runs out or the server says it
|
||||
* is off, so the caller can refetch `me` (and the Settings tab can show the base level again). */
|
||||
/** Apply `state` and `session` to this tab. `lapsed` is called when a raised level or a recording
|
||||
* runs out, or the server says it is off, so the caller can refetch `me` (and Settings and the
|
||||
* recorder show what is in force now). */
|
||||
export function applyDiagnostics(
|
||||
state: DiagnosticsState | null | undefined,
|
||||
session: DebugSession | null | undefined,
|
||||
lapsed: () => void,
|
||||
): void {
|
||||
onLapsed = lapsed;
|
||||
const level = levelInForce(state);
|
||||
const key = level === "INFO" ? "INFO" : `${level}@${state?.until}`;
|
||||
lastState = state;
|
||||
lastSession = session;
|
||||
const own = levelInForce(state);
|
||||
const live = liveSession(session);
|
||||
const bySession = !!live && DETAIL[live.level] > DETAIL[own];
|
||||
const level = bySession ? live.level : own;
|
||||
const until = bySession ? live.until : (state?.until ?? null);
|
||||
setDebugSession(live?.id ?? null);
|
||||
const key = level === "INFO" ? "INFO" : `${level}@${until}#${live?.id ?? ""}`;
|
||||
if (key === applied) return;
|
||||
const wasRaised = applied !== null && applied !== "INFO";
|
||||
applied = key;
|
||||
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
|
||||
lapseTimer = null;
|
||||
if (capFlushTimer !== null) window.clearTimeout(capFlushTimer);
|
||||
capFlushTimer = null;
|
||||
if (level === "INFO") {
|
||||
stopStreaming();
|
||||
setLevel("INFO");
|
||||
@@ -56,27 +94,46 @@ export function applyDiagnostics(
|
||||
}
|
||||
setLevel(level);
|
||||
startStreaming(); // first, so the stream opens with the line saying why it exists
|
||||
log.info("diagnostics.level", "diagnostics level raised", { level, until: state?.until });
|
||||
// setTimeout's delay is a signed 32-bit number; a longer wait would fire at once. The server
|
||||
// grants 24 h, so the clamp only matters for a clock that is badly off.
|
||||
const wait = Math.min(Date.parse(state!.until!) - Date.now(), 2 ** 31 - 1);
|
||||
log.info("diagnostics.level", "diagnostics level raised", {
|
||||
level,
|
||||
until,
|
||||
...(live ? { debug_session: live.id } : {}),
|
||||
});
|
||||
// Whichever runs out first — the level or the recording — changes what is in force. setTimeout's
|
||||
// delay is a signed 32-bit number; a longer wait would fire at once. The server grants 24 h, so
|
||||
// the clamp only matters for a clock that is badly off.
|
||||
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
|
||||
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
|
||||
lapseTimer = window.setTimeout(lapse, wait);
|
||||
if (live) {
|
||||
const lead = Date.parse(live.until) - CAP_FLUSH_LEAD_MS - Date.now();
|
||||
if (lead > 0 && lead < wait) capFlushTimer = window.setTimeout(() => void flush(), lead);
|
||||
}
|
||||
}
|
||||
|
||||
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */
|
||||
function lapse(): void {
|
||||
lapseTimer = null;
|
||||
applied = "INFO";
|
||||
stopStreaming();
|
||||
setLevel("INFO");
|
||||
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
|
||||
const before = applied;
|
||||
applied = null;
|
||||
applyDiagnostics(lastState, lastSession, onLapsed ?? (() => {}));
|
||||
if (applied === "INFO" && before !== "INFO")
|
||||
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
|
||||
onLapsed?.();
|
||||
}
|
||||
|
||||
/** The server has it off: lapse everything this tab thought was in force. */
|
||||
function serverSaysOff(): void {
|
||||
lastState = null;
|
||||
lastSession = null;
|
||||
lapse();
|
||||
}
|
||||
|
||||
// --- streaming ----------------------------------------------------------------------------------
|
||||
let queue: LogEvent[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let flushTimer: number | null = null;
|
||||
let inFlight = false;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let quietUntil = 0;
|
||||
// Which consent a batch was taken under. A batch still in flight when the level is lowered must not
|
||||
// come back into the queue of a LATER consent: it was captured under the one just withdrawn.
|
||||
@@ -114,9 +171,27 @@ function stopStreaming(): void {
|
||||
queue = [];
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (inFlight || !queue.length || Date.now() < quietUntil) return;
|
||||
inFlight = true;
|
||||
function flush(): Promise<void> {
|
||||
if (inFlight) return inFlight;
|
||||
if (!queue.length || Date.now() < quietUntil) return Promise.resolve();
|
||||
inFlight = sendBatch().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/** Send everything waiting, now — the recorder awaits it before Stop: once the server has closed
|
||||
* the session it no longer tags (or, at the base level, takes) the events of its last seconds,
|
||||
* which are the ones closest to what went wrong. Gives up on a batch the server turned away. */
|
||||
export async function flushNow(): Promise<void> {
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
await flush();
|
||||
if (inFlight) continue;
|
||||
if (!queue.length || !unsubscribe || Date.now() < quietUntil) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBatch(): Promise<void> {
|
||||
const takenIn = session;
|
||||
const batch = queue.splice(0, BATCH_MAX);
|
||||
try {
|
||||
@@ -138,10 +213,8 @@ async function flush(): Promise<void> {
|
||||
// means THIS tab does not already know — without it, the 409 answering a batch that was in
|
||||
// flight when the user picked Alap writes "lapsed" into their own log for a level they
|
||||
// revoked, and a lapse this tab has already announced gets announced a second time.
|
||||
lapse();
|
||||
serverSaysOff();
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
anyBlockingLayer,
|
||||
anyBlockingLayerAbove,
|
||||
isTopmost,
|
||||
markEscapeHandled,
|
||||
escapeHandled,
|
||||
@@ -77,3 +79,40 @@ describe("layerStack Escape coordination (UX-8)", () => {
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A layer answers TWO questions, and round 3 found they are not the same one: "do the page's own
|
||||
// keys stay quiet?" and "do I take the Tab trap from the dialog below?". The recorder's step prompt
|
||||
// is the case that separates them — a field inside whatever dialog the pill is docked in, so it
|
||||
// owns the keyboard but its two controls are no trap for that dialog's many.
|
||||
describe("owning the keyboard and owning the Tab trap", () => {
|
||||
it("an inline prompt silences the page and still leaves the dialog its trap", () => {
|
||||
const dialog = open();
|
||||
const prompt = pushLayer({ trapsTab: false });
|
||||
opened.push(prompt);
|
||||
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false); // the dialog keeps trapping Tab...
|
||||
// ...and the page's own keys are quiet by the PROMPT's doing, not the stand-in dialog's: the
|
||||
// dialog is non-passive too, so asserting this with it still on the stack asserted nothing.
|
||||
removeLayer(dialog);
|
||||
expect(anyBlockingLayer()).toBe(true); // `j`, `g h` and the rest stay off the page behind
|
||||
});
|
||||
|
||||
it("a real dialog above does take the trap from the one below", () => {
|
||||
const dialog = open();
|
||||
const above = open();
|
||||
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(true);
|
||||
expect(anyBlockingLayerAbove(above)).toBe(false);
|
||||
});
|
||||
|
||||
it("a passive layer takes neither: a hover card must silence nothing", () => {
|
||||
const card = pushLayer({ passive: true });
|
||||
opened.push(card);
|
||||
expect(anyBlockingLayer()).toBe(false); // `j` still moves the list under a resting pointer
|
||||
|
||||
const dialog = open();
|
||||
const over = pushLayer({ passive: true });
|
||||
opened.push(over);
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false); // ...and it is no trap either
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,17 +20,25 @@ let nextId = 1;
|
||||
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
|
||||
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
|
||||
const passive = new Set<number>();
|
||||
// Layers that DO own the keyboard but leave the Tab trap to the dialog below: a small inline prompt
|
||||
// inside one (the recorder's step field). It must silence the page's keys — an autofocused field
|
||||
// does — yet it is not a dialog of its own, so claiming the trap left the dialog around it with
|
||||
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
|
||||
// questions are separate; a layer answers each for itself (deep-review round 3).
|
||||
const defers = new Set<number>();
|
||||
|
||||
export function pushLayer(opts?: { passive?: boolean }): number {
|
||||
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
|
||||
const id = nextId++;
|
||||
stack.push(id);
|
||||
if (opts?.passive) passive.add(id);
|
||||
if (opts?.trapsTab === false) defers.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeLayer(id: number): void {
|
||||
stack = stack.filter((x) => x !== id);
|
||||
passive.delete(id);
|
||||
defers.delete(id);
|
||||
}
|
||||
|
||||
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
|
||||
@@ -51,12 +59,13 @@ export function anyLayerAbove(id: number): boolean {
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
/** True when a layer that owns the keyboard sits above `id` — the predicate for a dialog's Tab trap.
|
||||
* A tooltip or hover card open under a resting pointer takes Escape first, but not the Tab: deferring
|
||||
* the trap to it let Shift+Tab walk out of the dialog into the page behind its scrim. */
|
||||
/** True when a layer that TRAPS THE TAB sits above `id` — the predicate for a dialog's Tab trap. A
|
||||
* tooltip open under a resting pointer (passive) and an inline prompt inside the dialog itself
|
||||
* (`trapsTab: false`) both take Escape first but neither takes the Tab: deferring the trap to one
|
||||
* of those left Shift+Tab walking out of the dialog into the page behind its scrim. */
|
||||
export function anyBlockingLayerAbove(id: number): boolean {
|
||||
const i = stack.indexOf(id);
|
||||
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x));
|
||||
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x) && !defers.has(x));
|
||||
}
|
||||
|
||||
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
|
||||
@@ -83,17 +92,20 @@ export function escapeHandled(e: KeyboardEvent): boolean {
|
||||
export function useLayer(
|
||||
active: boolean,
|
||||
onEscape?: () => void,
|
||||
opts?: { passive?: boolean },
|
||||
opts?: { passive?: boolean; trapsTab?: boolean },
|
||||
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
|
||||
const idRef = useRef<number | null>(null);
|
||||
const escRef = useRef(onEscape);
|
||||
escRef.current = onEscape;
|
||||
const passiveRef = useRef(!!opts?.passive);
|
||||
passiveRef.current = !!opts?.passive;
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = pushLayer({ passive: passiveRef.current });
|
||||
const id = pushLayer({
|
||||
passive: !!optsRef.current?.passive,
|
||||
trapsTab: optsRef.current?.trapsTab,
|
||||
});
|
||||
idRef.current = id;
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface LogEvent {
|
||||
request_id?: string;
|
||||
tab_id: string;
|
||||
version: string;
|
||||
/** The debug session recording when the event was kept (E16). */
|
||||
debug_session_id?: string;
|
||||
}
|
||||
|
||||
/** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of
|
||||
@@ -138,6 +140,20 @@ const TAB_ID = tabId();
|
||||
// --- the logger -----------------------------------------------------------------------------------
|
||||
let threshold: Level = "INFO";
|
||||
let ring: LogEvent[] = [];
|
||||
// The debug session this tab is recording into (E16), or null. Held HERE, the lowest layer, because
|
||||
// two things below everything else need it: every kept event carries it, and `api/core` puts it on
|
||||
// every request as `X-Debug-Session` — which is how the server tags its own lines of that request.
|
||||
let debugSession: string | null = null;
|
||||
|
||||
/** Record into `id` from now on (null = stop). `lib/diagnostics` sets it from `me.debug_session`. */
|
||||
export function setDebugSession(id: string | null): void {
|
||||
debugSession = id;
|
||||
}
|
||||
|
||||
/** The debug session recording right now, if any. */
|
||||
export function currentDebugSession(): string | null {
|
||||
return debugSession;
|
||||
}
|
||||
|
||||
/** The capture level. INFO is the always-on "Alap"; DEBUG and TRACE come from the user's consented
|
||||
* diagnostics level (E22 S5). */
|
||||
@@ -207,6 +223,7 @@ function emit(
|
||||
};
|
||||
if (attrs && Object.keys(attrs).length) entry.attrs = redact(attrs) as Record<string, unknown>;
|
||||
if (requestId) entry.request_id = requestId;
|
||||
if (debugSession) entry.debug_session_id = debugSession;
|
||||
ring.push(entry);
|
||||
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
||||
notify(entry);
|
||||
|
||||
@@ -95,6 +95,17 @@ export const qk = {
|
||||
adminLogs: (filters: object) => ["admin-logs", filters] as const,
|
||||
adminLogFacets: (filters: object) => ["admin-log-facets", filters] 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,
|
||||
// The tracker's rendering of a body: the text is the key. Nothing debounces it — react-query has
|
||||
// no such thing — so one fetch per distinct text. What holds the count down is that the composer
|
||||
// unmounts the editor while the preview is up, plus the cache for an unchanged draft; a preview
|
||||
// shown BESIDE the fields would need a real debounce first (see routes/debug_sessions.py).
|
||||
debugSessionPreview: (id: string, text: string) =>
|
||||
["debug-sessions", id, "preview", text] as const,
|
||||
adminDownloads: () => ["admin-downloads"] as const,
|
||||
adminQuota: (days: number) => ["admin-quota", days] as const,
|
||||
demoWhitelist: () => ["demo-whitelist"] as const,
|
||||
|
||||
@@ -14,6 +14,30 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.93.0",
|
||||
date: "2026-09-20",
|
||||
summary:
|
||||
"A way to report a fault from inside the app: record what you did, mark the steps, attach screenshots — and the app writes the report for you, with the logs of exactly that session attached.",
|
||||
features: [
|
||||
"Start a recording (the command palette, or Settings → Diagnostics) and a pill follows you: a timer, “Step” to mark what you just did, a screenshot button, and Stop. Everything you and the server do while it runs is collected under that one session, and nothing else is.",
|
||||
"Stop opens the composer: a drafted title and description, the steps you marked interleaved with the pages you visited, a table of your browser and the app's version, and the attachments — each one reviewable, each one removable.",
|
||||
"The log lines that would be sent can be read before they go, line by line, and any of them left out.",
|
||||
"Screenshots come from a paste, a drop, a file, or capturing the tab — up to eight, and any of them can be dropped again before sending.",
|
||||
"Send opens an issue on the project's tracker with the report and its logs attached, and the preview shows it exactly as the tracker will render it. Five reports an hour.",
|
||||
"Without a tracker configured — or whenever you would rather keep it — the same report downloads as a zip instead.",
|
||||
"An administrator sets the tracker up in Settings → System (address, repository, token) and can test the connection there. The server refuses to send to a public repository: a report carries its reporter's log.",
|
||||
],
|
||||
fixes: [
|
||||
"The Plex player keeps the keyboard inside itself: Tab no longer walks out of a playing film into the page behind it, not even while its own settings panel is open.",
|
||||
"A report cannot be sent twice at once, from any tab: while one send runs the others say so, and a send that fails half-way retries only what is missing — never a second issue.",
|
||||
"A recording ends by itself after an hour, and both Stop and that hour return your diagnostics level to whatever it was before you started.",
|
||||
],
|
||||
chores: [
|
||||
"A session's logs and screenshots are purged with the session after 7 days, like every other diagnostic line, and another account's session id is ignored rather than refused.",
|
||||
"Four adversarial review rounds at the epic's end: 80 findings fixed across them, every regression test proven to fail with its own fix removed, and the keyboard and focus work measured in a real browser rather than argued.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.92.0",
|
||||
date: "2026-09-19",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { createStore } from "./store";
|
||||
|
||||
// A control that lives where the toasts live, under them (E16: the debug recorder's pill).
|
||||
//
|
||||
// The toast stack already solves the hard part of "always visible, never in the way": it has a host
|
||||
// in the page and one inside every overlay — the dialogs, both players, the fullscreen player — the
|
||||
// topmost host shows it, and inside an overlay it sits above the player's control band. A control
|
||||
// that must stay reachable while the user reproduces a bug anywhere needs exactly that, so it rides
|
||||
// the same hosts instead of rebuilding them: `ui/Toaster` renders whatever is docked here below its
|
||||
// stack, in the host that is on top.
|
||||
//
|
||||
// One slot, not a list: one surface uses it, and a second would have to decide how they share the
|
||||
// corner before this grows.
|
||||
const docked = createStore<ComponentType | null>(null);
|
||||
|
||||
/** Dock `component` under the toasts until the returned function undocks it. */
|
||||
export function dockUnderToasts(component: ComponentType): () => void {
|
||||
// Always the updater form: a component IS a function, so `set(component)` would be taken for one
|
||||
// and called with the previous value instead of stored.
|
||||
docked.set(() => component);
|
||||
return () =>
|
||||
docked.set((current: ComponentType | null) => (current === component ? null : current));
|
||||
}
|
||||
|
||||
/** What is docked (the Toaster reads it). */
|
||||
export const useToastDock = (): ComponentType | null => docked.use();
|
||||
@@ -15,8 +15,14 @@ export function useDismiss(
|
||||
onClose: () => void,
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
): void {
|
||||
// Registration + topmost-only Escape.
|
||||
useLayer(active, onClose);
|
||||
// Registration + topmost-only Escape. `trapsTab: false`, because this hook installs NO Tab trap:
|
||||
// it owns Escape and the outside-press, nothing more. Claiming the trap made a dialog underneath
|
||||
// stand its own down (`anyBlockingLayerAbove`) for a layer that traps nothing, so Tab walked out
|
||||
// of the dialog while one of its own menus was open — measured in a real browser on the Plex
|
||||
// player, whose settings and track menus are both dismissables (deep-review round 4). Every
|
||||
// consumer renders its panel INSIDE the surface below it, so that surface's trap already spans
|
||||
// the panel; a portaled one would need its own trap and must not use this default.
|
||||
useLayer(active, onClose, { trapsTab: false });
|
||||
|
||||
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
|
||||
// that lands outside every registered element.
|
||||
|
||||
Reference in New Issue
Block a user