feat(diagnostics): send a bug report to a private Gitea issue tracker (E16 S4)
- admin config group "Bug reports": Gitea address, repository, token (secret),
and "Check the connection" — the repository must answer, take issues and
be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
upload each file on its own request (the measured API), then show the
screenshots inline; progress is kept in the draft, so a send that stops
half-way keeps its issue and the retry sends only what is missing; 5 new
issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
repository), a partial-send banner with retry, and a Preview — the tracker's
own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -145,6 +145,8 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
|
||||
"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 (app/issues/gitea.py): the files still to go.
|
||||
"pending_files": ((session.draft or {}).get("delivery") or {}).get("pending") or [],
|
||||
"screenshots": screenshots,
|
||||
"draft": session.draft,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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), so a failure half-way keeps the issue that exists and a retry
|
||||
sends only what is missing — never a second issue. Every step flushes; the caller commits, also
|
||||
when a step fails, so what did happen is kept.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import 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, …)
|
||||
]
|
||||
|
||||
|
||||
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(db: Session) -> Iterator[tuple[httpx.Client, str]]:
|
||||
url, repo, token = _settings(db)
|
||||
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 < 400:
|
||||
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 _private_repo(client: httpx.Client, repo: str) -> dict:
|
||||
data = _call(client, "GET", f"/repos/{repo}").json()
|
||||
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(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(db) as (client, repo):
|
||||
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo})
|
||||
return r.text
|
||||
|
||||
|
||||
def _save(session: DebugSession, state: dict) -> None:
|
||||
# A new dict, so the JSONB column sees the change.
|
||||
session.draft = {**(session.draft or {}), "delivery": state}
|
||||
|
||||
|
||||
def deliver(db: Session, session: DebugSession) -> str:
|
||||
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step."""
|
||||
state = dict((session.draft or {}).get("delivery") or {})
|
||||
files = bundle.files(db, session)
|
||||
attachments = [f for f in files if f.name != "issue.md"]
|
||||
with _client(db) as (client, repo):
|
||||
_private_repo(client, repo)
|
||||
if "number" not in state:
|
||||
created = _call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues",
|
||||
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
||||
).json()
|
||||
state = {
|
||||
"number": created["number"],
|
||||
"pending": [f.name for f in attachments],
|
||||
"links": {},
|
||||
}
|
||||
session.issue_url = created["html_url"]
|
||||
_save(session, state)
|
||||
db.flush()
|
||||
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 = _call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assets",
|
||||
params={"name": item.name},
|
||||
files={"attachment": (item.name, item.data, item.mime)},
|
||||
).json()
|
||||
state = {
|
||||
**state,
|
||||
"pending": [n for n in state["pending"] if n != item.name],
|
||||
"links": {**state["links"], item.name: asset["browser_download_url"]},
|
||||
}
|
||||
_save(session, state)
|
||||
db.flush()
|
||||
shots = [n for n in state["links"] if n.startswith("screenshot-")]
|
||||
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})
|
||||
_save(session, {**state, "images_shown": True})
|
||||
db.flush()
|
||||
return session.issue_url or ""
|
||||
|
||||
|
||||
def pending(session: DebugSession) -> list[str]:
|
||||
"""The files a half-finished delivery still has to send."""
|
||||
return list(((session.draft or {}).get("delivery") or {}).get("pending") or [])
|
||||
@@ -13,6 +13,8 @@ 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)
|
||||
@@ -75,3 +77,24 @@ async def capped_body(request: Request, limit: int) -> bytes:
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,12 +20,15 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import debugsession, logs, logsink
|
||||
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
|
||||
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, not_found, owned_or_404
|
||||
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"])
|
||||
@@ -58,7 +61,7 @@ def start_session(
|
||||
db.rollback()
|
||||
session = debugsession.open_session(db, user)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=409, detail={"code": "session_conflict"})
|
||||
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)
|
||||
@@ -139,7 +142,7 @@ def save_draft(
|
||||
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"})
|
||||
raise refused(409, "issue_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())
|
||||
@@ -167,6 +170,72 @@ def export_session(
|
||||
)
|
||||
|
||||
|
||||
# --- the issue tracker (S4) -----------------------------------------------------------------
|
||||
# Issues a user may OPEN per hour — finishing a half-sent one 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)
|
||||
|
||||
|
||||
@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")
|
||||
new = session.issue_url is None
|
||||
if not new and not gitea.pending(session):
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
if new:
|
||||
if not ((session.draft or {}).get("title") or "").strip():
|
||||
raise refused(422, "issue_title_required")
|
||||
if not _new_issues.allow(str(user.id)):
|
||||
raise refused(429, "issues_rate_limited")
|
||||
try:
|
||||
gitea.deliver(db, session)
|
||||
except gitea.GiteaError as exc:
|
||||
# Keep what did happen: an issue created before an upload failed stays linked, and the
|
||||
# retry sends only the files still missing.
|
||||
_record_created(db, user, session, new)
|
||||
db.commit()
|
||||
raise issue_refused(exc) from None
|
||||
_record_created(db, user, session, new)
|
||||
db.commit()
|
||||
return {"ok": True, "session": _view(db, session)}
|
||||
|
||||
|
||||
def _record_created(db: Session, user: User, session: DebugSession, new: bool) -> None:
|
||||
if new and session.issue_url:
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.ISSUE_CREATE,
|
||||
target_type="debug_session",
|
||||
target_id=session.id,
|
||||
summary=session.issue_url,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
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)}
|
||||
@@ -201,13 +270,23 @@ def add_screenshot(
|
||||
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
|
||||
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 == "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:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
shot = db.get(DebugScreenshot, shot_id)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.auth import (
|
||||
purge_user,
|
||||
)
|
||||
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
|
||||
@@ -160,6 +161,8 @@ def get_me(
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.)
|
||||
)
|
||||
|
||||
@@ -10,15 +10,17 @@ Four parts:
|
||||
* screenshots: judged by their bytes, capped in size and count, served back only to their owner.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import debugsession, logs
|
||||
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
||||
from app.models import AuditLog, DebugScreenshot, DebugSession, LogEvent, User
|
||||
from app.routes import diagnostics as diagnostics_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
@@ -406,13 +408,14 @@ def test_a_screenshot_is_kept_as_what_its_bytes_are(recording, data, mime):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "reason"), [(b"<svg onload=alert(1)>", "not_an_image"), (b"", "empty")]
|
||||
("data", "reason"),
|
||||
[(b"<svg onload=alert(1)>", "screenshot_not_an_image"), (b"", "screenshot_empty")],
|
||||
)
|
||||
def test_what_is_not_an_image_is_refused(recording, data, reason):
|
||||
client, sid = recording
|
||||
r = _upload(client, sid, data)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == {"code": "screenshot_refused", "reason": reason}
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": reason}
|
||||
|
||||
|
||||
def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch):
|
||||
@@ -422,7 +425,11 @@ def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch):
|
||||
for _ in range(debugsession.SCREENSHOTS_MAX):
|
||||
assert _upload(client, sid, _PNG).status_code == 200
|
||||
r = _upload(client, sid, _PNG)
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "too_many"
|
||||
assert r.status_code == 422 and r.json()["detail"] == {
|
||||
"code": "refused",
|
||||
"reason": "screenshot_too_many",
|
||||
"limit": debugsession.SCREENSHOTS_MAX,
|
||||
}
|
||||
assert (
|
||||
client.get(f"/api/debug-sessions/{sid}").json()["screenshots"]
|
||||
== debugsession.SCREENSHOTS_MAX
|
||||
@@ -434,7 +441,7 @@ def test_a_sent_session_takes_no_more_screenshots(db, recording):
|
||||
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
|
||||
db.commit()
|
||||
r = _upload(client, sid, _PNG)
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "already_sent"
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent"
|
||||
|
||||
|
||||
def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
|
||||
@@ -486,7 +493,7 @@ def test_a_sent_session_keeps_its_draft(db, recording):
|
||||
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
|
||||
db.commit()
|
||||
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"})
|
||||
assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent"
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_already_sent"
|
||||
|
||||
|
||||
def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user):
|
||||
@@ -543,3 +550,198 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make
|
||||
assert "kept click" in web and "private search" not in web
|
||||
assert "GET /api/x 200" in api and all(m != "kept click" for m in api)
|
||||
assert z.read("screenshot-1.png") == _PNG
|
||||
|
||||
|
||||
# --- the issue tracker (S4) -----------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
"""Gitea 1.27 as measured (plans/e16 S0): a private repository, one file per asset upload, and
|
||||
a way to make one upload fail."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.private = True
|
||||
self.has_issues = True
|
||||
self.token_ok = True
|
||||
self.fail_asset: str | None = None
|
||||
self.patched_body: str | None = None
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path.removeprefix("/api/v1")
|
||||
self.calls.append((request.method, path))
|
||||
if request.headers.get("authorization") != "token t0ken" or not self.token_ok:
|
||||
return httpx.Response(401, json={"message": "invalid username, password or token"})
|
||||
if request.method == "GET" and path == "/repos/o/r":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "o/r",
|
||||
"private": self.private,
|
||||
"has_issues": self.has_issues,
|
||||
"html_url": "https://git.example/o/r",
|
||||
},
|
||||
)
|
||||
if request.method == "POST" and path == "/repos/o/r/issues":
|
||||
return httpx.Response(
|
||||
201, json={"number": 7, "html_url": "https://git.example/o/r/issues/7"}
|
||||
)
|
||||
if request.method == "POST" and path == "/repos/o/r/issues/7/assets":
|
||||
name = request.url.params["name"]
|
||||
if name == self.fail_asset:
|
||||
self.fail_asset = None
|
||||
return httpx.Response(500, json={"message": "boom"})
|
||||
return httpx.Response(
|
||||
201, json={"name": name, "browser_download_url": f"https://git.example/a/{name}"}
|
||||
)
|
||||
if request.method == "PATCH" and path == "/repos/o/r/issues/7":
|
||||
self.patched_body = json.loads(request.content)["body"]
|
||||
return httpx.Response(201, json={"number": 7})
|
||||
if request.method == "POST" and path == "/markdown":
|
||||
return httpx.Response(200, text="<h3>Steps</h3>")
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
def uploads(self) -> list[str]:
|
||||
return [p for m, p in self.calls if p.endswith("/assets")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_fake(monkeypatch):
|
||||
from app.config import settings
|
||||
from app.issues import gitea
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
fake = FakeGitea()
|
||||
real = httpx.Client
|
||||
monkeypatch.setattr(
|
||||
gitea.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(fake.handler), **kw)
|
||||
)
|
||||
monkeypatch.setattr(settings, "issues_gitea_url", "https://git.example/")
|
||||
monkeypatch.setattr(settings, "issues_gitea_repo", "o/r")
|
||||
monkeypatch.setattr(settings, "issues_gitea_token", "t0ken")
|
||||
routes._new_issues.reset()
|
||||
yield fake
|
||||
routes._new_issues.reset()
|
||||
|
||||
|
||||
def _ready(client, sid, title="Spinner stays"):
|
||||
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": title, "body": "It spins."})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_without_a_tracker_the_report_can_only_be_downloaded(db, client, make_user):
|
||||
_signed_in(client, make_user)
|
||||
assert client.get("/api/me").json()["issues"] is None
|
||||
sid = _start(client)["id"]
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 400 and r.json()["detail"]["reason"] == "issues_not_configured"
|
||||
|
||||
|
||||
def test_an_issue_is_created_with_every_file_and_the_screenshots_shown(
|
||||
gitea_fake, db, client, make_user
|
||||
):
|
||||
_signed_in(client, make_user)
|
||||
assert client.get("/api/me").json()["issues"] == {"repo": "o/r"}
|
||||
sid = _start(client)["id"]
|
||||
_upload(client, sid, _PNG)
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 200, r.text
|
||||
session = r.json()["session"]
|
||||
assert session["issue_url"] == "https://git.example/o/r/issues/7"
|
||||
assert session["pending_files"] == []
|
||||
# One file per request (the measured API), each by its name.
|
||||
assert gitea_fake.uploads() == ["/repos/o/r/issues/7/assets"] * 3
|
||||
assert "" in gitea_fake.patched_body
|
||||
assert gitea_fake.patched_body.startswith("It spins.")
|
||||
row = db.scalar(select(AuditLog).where(AuditLog.action == "issue_create"))
|
||||
assert (row.target_id, row.summary) == (sid, "https://git.example/o/r/issues/7")
|
||||
# Sent: the draft is final, and sending again sends nothing.
|
||||
assert (
|
||||
client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}).status_code
|
||||
== 409
|
||||
)
|
||||
calls = len(gitea_fake.calls)
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
|
||||
assert len(gitea_fake.calls) == calls
|
||||
|
||||
|
||||
def test_a_public_repository_gets_nothing(gitea_fake, db, client, make_user):
|
||||
gitea_fake.private = False
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_public_repo"
|
||||
assert [m for m, _ in gitea_fake.calls] == ["GET"]
|
||||
|
||||
|
||||
def test_a_send_that_stops_half_way_keeps_the_issue_and_the_retry_finishes_it(
|
||||
gitea_fake, db, client, make_user
|
||||
):
|
||||
gitea_fake.fail_asset = "server.log"
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable"
|
||||
session = client.get(f"/api/debug-sessions/{sid}").json()
|
||||
assert session["issue_url"] == "https://git.example/o/r/issues/7"
|
||||
assert session["pending_files"] == ["server.log"]
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 200 and r.json()["session"]["pending_files"] == []
|
||||
# One issue, and the retry sent only what was missing.
|
||||
assert [p for m, p in gitea_fake.calls if (m, p) == ("POST", "/repos/o/r/issues")] == [
|
||||
"/repos/o/r/issues"
|
||||
]
|
||||
assert len(gitea_fake.uploads()) == 3 # client.log, the failed server.log, then server.log
|
||||
|
||||
|
||||
def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user):
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issue_title_required"
|
||||
_ready(client, sid)
|
||||
gitea_fake.token_ok = False
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_bad_token"
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["issue_url"] is None
|
||||
|
||||
|
||||
def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monkeypatch):
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
monkeypatch.setattr(routes, "_new_issues", RateLimiter(max_events=1, window_seconds=3600))
|
||||
client = as_user(make_user())
|
||||
first = _start(client)["id"]
|
||||
_ready(client, first)
|
||||
assert client.post(f"/api/debug-sessions/{first}/issue").status_code == 200
|
||||
client.post(f"/api/debug-sessions/{first}/stop")
|
||||
second = _start(client)["id"]
|
||||
_ready(client, second)
|
||||
r = client.post(f"/api/debug-sessions/{second}/issue")
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "issues_rate_limited"
|
||||
|
||||
|
||||
def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as_user):
|
||||
owner, stranger = as_user(make_user()), as_user(make_user())
|
||||
sid = _start(owner)["id"]
|
||||
r = owner.post(f"/api/debug-sessions/{sid}/preview", json={"text": "### Steps"})
|
||||
assert r.json() == {"ok": True, "html": "<h3>Steps</h3>"}
|
||||
assert (
|
||||
stranger.post(f"/api/debug-sessions/{sid}/preview", json={"text": "x"}).status_code == 404
|
||||
)
|
||||
assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404
|
||||
|
||||
|
||||
def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user):
|
||||
admin = as_user(make_user(role="admin"))
|
||||
r = admin.post("/api/admin/config/test-issues")
|
||||
assert r.json() == {"ok": True, "repo": "o/r", "url": "https://git.example/o/r"}
|
||||
gitea_fake.has_issues = False
|
||||
r = admin.post("/api/admin/config/test-issues")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_disabled"
|
||||
assert as_user(make_user()).post("/api/admin/config/test-issues").status_code == 403
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -34,6 +34,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
|
||||
ended_at: null,
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
||||
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")>();
|
||||
@@ -28,6 +29,8 @@ const api = vi.hoisted(() => ({
|
||||
saved: [] as DebugDraft[],
|
||||
uploaded: [] as Blob[],
|
||||
failSession: false,
|
||||
sent: 0,
|
||||
previewed: [] as string[],
|
||||
}));
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
@@ -51,6 +54,19 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
});
|
||||
},
|
||||
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
|
||||
sendDebugIssue: () => {
|
||||
api.sent += 1;
|
||||
api.session = {
|
||||
...api.session,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: [],
|
||||
};
|
||||
return Promise.resolve({ ok: true, 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>" });
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -84,6 +100,7 @@ const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
|
||||
ended_at: "2026-09-19T14:16:00.000Z",
|
||||
ended_reason: "stopped",
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
@@ -99,14 +116,20 @@ beforeEach(() => {
|
||||
api.saved = [];
|
||||
api.uploaded = [];
|
||||
api.failSession = 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} />
|
||||
@@ -222,4 +245,49 @@ describe("the issue composer", () => {
|
||||
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(screen.getByRole("button", { name: "recorder.composer.send" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
expect(api.saved).toHaveLength(1); // saved first: the issue is built from what was saved
|
||||
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 { notify } from "../lib/notifications";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import {
|
||||
draftSteps,
|
||||
environment,
|
||||
@@ -20,6 +22,7 @@ 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
|
||||
@@ -165,6 +168,25 @@ function ComposerBody({
|
||||
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 sends only what is missing.
|
||||
const target = useMe()?.issues ?? null;
|
||||
const pending = session.pending_files;
|
||||
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: () => void qc.invalidateQueries({ queryKey: qk.debugSessions() }),
|
||||
});
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
|
||||
// The export is built from the SAVED draft: save what is on screen first.
|
||||
const exportZip = async () => {
|
||||
if (!sent) await save.mutateAsync();
|
||||
@@ -191,6 +213,19 @@ function ComposerBody({
|
||||
level: t(`settings.diagnostics.levels.${session.level}`),
|
||||
})}
|
||||
</p>
|
||||
{sent && pending.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
|
||||
<span>{t("recorder.composer.partial", { files: pending.join(", ") })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={send.isPending}
|
||||
onClick={() => send.mutate()}
|
||||
>
|
||||
{t("recorder.composer.retry")}
|
||||
</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>
|
||||
@@ -205,7 +240,26 @@ function ComposerBody({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{reviewing ? (
|
||||
{!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(
|
||||
@@ -239,12 +293,17 @@ function ComposerBody({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<span className="mr-auto text-xs text-muted" aria-live="polite">
|
||||
{save.isPending
|
||||
? t("recorder.composer.saving")
|
||||
: save.isSuccess
|
||||
? t("recorder.composer.saved")
|
||||
: ""}
|
||||
<span 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
|
||||
? t("recorder.composer.saved")
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="ghost" onClick={closeComposer}>
|
||||
{t("recorder.composer.close")}
|
||||
@@ -252,6 +311,11 @@ function ComposerBody({
|
||||
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
|
||||
{t("recorder.composer.export")}
|
||||
</Button>
|
||||
{target && !sent && (
|
||||
<Button variant="primary" loading={send.isPending} onClick={() => send.mutate()}>
|
||||
{t("recorder.composer.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -563,3 +627,62 @@ function ReviewList({
|
||||
</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.debugSession(sessionId), "preview", 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;
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
|
||||
body{margin:0;padding:12px 14px;font:14px/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:4px 8px}
|
||||
code{font-family:ui-monospace,Consolas,monospace;font-size:12px}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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -207,6 +207,21 @@
|
||||
"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 5 reports an hour. Try again later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,19 @@
|
||||
"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."
|
||||
},
|
||||
"issue": {
|
||||
"steps": "Steps",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -207,6 +207,21 @@
|
||||
"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 5 hibajegyet küldhetsz. Próbáld újra később."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,19 @@
|
||||
"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."
|
||||
},
|
||||
"issue": {
|
||||
"steps": "Lépések",
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface Me {
|
||||
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" }),
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface DebugSession {
|
||||
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[];
|
||||
screenshots: number;
|
||||
/** The issue being written from it (the composer saves as it goes); null until it first does. */
|
||||
draft: DebugDraft | null;
|
||||
@@ -107,6 +109,12 @@ export const debugSessionsApi = {
|
||||
const url = `${SESSIONS}/${id}/export`;
|
||||
return a != null ? `${url}?account=${a}` : url;
|
||||
},
|
||||
/** 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
|
||||
|
||||
@@ -37,6 +37,7 @@ const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
|
||||
ended_at: null,
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
|
||||
Reference in New Issue
Block a user