After Stop (or at the cap, or from Settings → Diagnosztika's past sessions) one dialog drafts the issue: title, description, the steps drafted from the session's markers and page changes (a marker without a label is named by its time), the environment (version, browser, system, window, language). Beside it, what goes with it: the browser and server logs — each reviewable line by line, an unticked line is not sent — and the screenshots, removable, with paste / drop / pick to add more. - it saves as it goes (debug_sessions.draft, migration 0090): the rendered markdown in the user's language, the excluded rows, and its own fields to reopen as it was; a sent session is read-only - the zip export (issue.md, client.log, server.log, screenshots) is built from the saved draft by app/issues/bundle.py — the one definition the Gitea delivery (S4) will share; .log, not .jsonl, which Gitea refuses - the log line's level tags and clock move to components/logLineParts so the review list draws a level the way both log viewers do
240 lines
9.3 KiB
Python
240 lines
9.3 KiB
Python
"""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 datetime import datetime, timezone
|
|
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 debugsession, logs, logsink
|
|
from app.auth import require_human
|
|
from app.db import get_db
|
|
from app.issues import bundle
|
|
from app.models import DebugScreenshot, DebugSession, User
|
|
from app.routes._common import capped_body, not_found, owned_or_404
|
|
from app.utils import iso
|
|
|
|
router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"])
|
|
log = logs.get_logger(__name__)
|
|
|
|
# Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft.
|
|
LIST_MAX = 20
|
|
|
|
|
|
class StartIn(BaseModel):
|
|
level: debugsession.SessionLevel
|
|
|
|
|
|
def _view(db: Session, session: DebugSession) -> dict:
|
|
return debugsession.serialize(session, debugsession.screenshot_count(db, session)) or {}
|
|
|
|
|
|
@router.post("")
|
|
def start_session(
|
|
body: StartIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Start recording, or join the recording already running (another tab started it)."""
|
|
try:
|
|
session = debugsession.start(db, user, body.level)
|
|
db.commit()
|
|
except IntegrityError:
|
|
# A concurrent Start won the one-open-per-user index: join that one.
|
|
db.rollback()
|
|
session = debugsession.open_session(db, user)
|
|
if session is None:
|
|
raise HTTPException(status_code=409, detail={"code": "session_conflict"})
|
|
# The rest of THIS request is part of the session too.
|
|
level, until = debugsession.level_for(user, session)
|
|
logs.set_user(user.id, level, until, session.id)
|
|
log.info(
|
|
"debug session started",
|
|
extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)),
|
|
)
|
|
return {"ok": True, "session": _view(db, session)}
|
|
|
|
|
|
@router.post("/{sid}/stop")
|
|
def stop_session(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
if session.ended_at is None:
|
|
# Logged BEFORE the session closes, while this request still carries its tag: the last line
|
|
# of the evidence is the Stop itself.
|
|
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
|
|
# Past its cap it had already ended on its own; a late Stop must not rename that.
|
|
live = session.until > datetime.now(timezone.utc)
|
|
reason = debugsession.EndReason.STOPPED if live else debugsession.EndReason.EXPIRED
|
|
debugsession.end(session, reason)
|
|
db.commit()
|
|
return {"ok": True, "session": _view(db, session)}
|
|
|
|
|
|
@router.get("")
|
|
def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
|
sessions = db.scalars(
|
|
select(DebugSession)
|
|
.where(DebugSession.user_id == user.id)
|
|
.order_by(DebugSession.started_at.desc())
|
|
.limit(LIST_MAX)
|
|
).all()
|
|
return {"items": [_view(db, s) for s in sessions]}
|
|
|
|
|
|
@router.get("/{sid}")
|
|
def get_session(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
|
|
|
|
|
|
@router.get("/{sid}/events")
|
|
def session_events(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""The rows an issue from this session would carry — browser and server, in time order."""
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
rows, total = debugsession.bundle(db, session)
|
|
return {"items": [logsink.serialize(r) for r in rows], "total": total}
|
|
|
|
|
|
# --- the draft and its export ---------------------------------------------------------------
|
|
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
|
|
# was — bounded here so a draft cannot become a place to store anything else.
|
|
FORM_MAX_BYTES = 64 * 1024
|
|
|
|
|
|
class DraftIn(BaseModel):
|
|
title: str = Field(max_length=200)
|
|
# The rendered markdown the export and the issue carry.
|
|
body: str = Field(max_length=50_000)
|
|
# Log rows taken out of the evidence in the review.
|
|
excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX)
|
|
form: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
@router.put("/{sid}/draft")
|
|
def save_draft(
|
|
sid: str,
|
|
body: DraftIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
if session.issue_url is not None:
|
|
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
|
|
raise HTTPException(status_code=409, detail={"code": "already_sent"})
|
|
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
|
debugsession.save_draft(session, body.model_dump())
|
|
db.commit()
|
|
return {"ok": True, "session": _view(db, session)}
|
|
|
|
|
|
@router.get("/{sid}/export")
|
|
def export_session(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> Response:
|
|
"""The issue as a zip — the text, the two logs and the screenshots — for an instance without an
|
|
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
|
|
what it saved."""
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
data = bundle.zipped(bundle.files(db, session))
|
|
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
|
|
return Response(
|
|
content=data,
|
|
media_type="application/zip",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{name}"',
|
|
"Cache-Control": "no-store",
|
|
},
|
|
)
|
|
|
|
|
|
# --- screenshots ---------------------------------------------------------------------------
|
|
def _shot_view(shot: DebugScreenshot) -> dict:
|
|
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
|
|
|
|
|
|
@router.get("/{sid}/screenshots")
|
|
def list_screenshots(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
|
|
|
|
|
|
def _upload_target(
|
|
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> DebugSession:
|
|
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
|
|
|
|
async def _image_body(request: Request) -> bytes:
|
|
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
|
|
|
|
|
|
@router.post("/{sid}/screenshots")
|
|
def add_screenshot(
|
|
# The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused
|
|
# before a byte of it is read.
|
|
session: DebugSession = Depends(_upload_target),
|
|
data: bytes = Depends(_image_body),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
try:
|
|
shot = debugsession.add_screenshot(db, session, data)
|
|
except debugsession.ScreenshotRefused as exc:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "screenshot_refused", "reason": exc.code}
|
|
) from exc
|
|
db.commit()
|
|
return {"ok": True, "screenshot": _shot_view(shot)}
|
|
|
|
|
|
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
shot = db.get(DebugScreenshot, shot_id)
|
|
if shot is None or shot.session_id != session.id:
|
|
raise not_found("screenshot")
|
|
return shot
|
|
|
|
|
|
@router.get("/{sid}/screenshots/{shot_id}")
|
|
def get_screenshot(
|
|
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> Response:
|
|
shot = _session_screenshot(db, sid, shot_id, user)
|
|
# `nosniff`: the type was checked against the bytes at upload, and the browser must not guess.
|
|
return Response(
|
|
content=shot.data,
|
|
media_type=shot.mime,
|
|
headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"},
|
|
)
|
|
|
|
|
|
@router.delete("/{sid}/screenshots/{shot_id}")
|
|
def delete_screenshot(
|
|
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
shot = _session_screenshot(db, sid, shot_id, user)
|
|
db.delete(shot)
|
|
db.commit()
|
|
return {"ok": True, "deleted": shot_id}
|