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
78 lines
2.8 KiB
Python
78 lines
2.8 KiB
Python
"""What an issue from a debug session carries, as files — one definition for the zip export and the
|
|
Gitea upload, so the two cannot drift apart.
|
|
|
|
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
|
|
in their language; the server does not rewrite it);
|
|
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
|
|
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
|
|
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
|
|
* the screenshots, in the order they were taken.
|
|
|
|
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
|
|
field the review list did not show.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import debugsession, logsink
|
|
from app.models import DebugScreenshot, DebugSession
|
|
|
|
_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IssueFile:
|
|
name: str
|
|
data: bytes
|
|
mime: str
|
|
|
|
|
|
def title_of(session: DebugSession) -> str:
|
|
# Not a sentence of ours: the composer always saves a title in the user's language, and a
|
|
# session exported before it ever saved is named by its date alone, in no language at all.
|
|
title = ((session.draft or {}).get("title") or "").strip()
|
|
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
|
|
|
|
|
|
def body_of(session: DebugSession) -> str:
|
|
return ((session.draft or {}).get("body") or "").strip()
|
|
|
|
|
|
def _lines(rows) -> bytes:
|
|
return "".join(
|
|
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
|
|
).encode()
|
|
|
|
|
|
def files(db: Session, session: DebugSession) -> list[IssueFile]:
|
|
"""The session's issue as files: the text, the two logs, the screenshots."""
|
|
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
|
|
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
|
|
out = [
|
|
IssueFile("issue.md", text.encode(), "text/markdown"),
|
|
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
|
|
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
|
|
]
|
|
shots = db.scalars(
|
|
select(DebugScreenshot)
|
|
.where(DebugScreenshot.session_id == session.id)
|
|
.order_by(DebugScreenshot.id)
|
|
)
|
|
for n, shot in enumerate(shots, start=1):
|
|
out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime))
|
|
return out
|
|
|
|
|
|
def zipped(items: list[IssueFile]) -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for item in items:
|
|
z.writestr(item.name, item.data)
|
|
return buf.getvalue()
|