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:
2026-09-19 22:48:20 +02:00
parent a5fae489e0
commit 9cf177b198
26 changed files with 883 additions and 32 deletions
+3
View File
@@ -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(
+7
View File
@@ -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).
+2
View File
@@ -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,
}
+194
View File
@@ -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"![{n}]({state['links'][n]})" 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 [])
+23
View File
@@ -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")
+15 -1
View File
@@ -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
+87 -8
View File
@@ -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)
+3
View File
@@ -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),
}
+4
View File
@@ -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.)
)
+208 -6
View File
@@ -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 "![screenshot-1.png](https://git.example/a/screenshot-1.png)" 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
+24
View File
@@ -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);
});
});
+130 -7
View File
@@ -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"
}
+19 -2
View File
@@ -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"
}
+16 -1
View File
@@ -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."
}
}
+13 -1
View File
@@ -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"
}
+19 -2
View File
@@ -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"
}
+16 -1
View File
@@ -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."
}
}
+13 -1
View File
@@ -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",
+2
View File
@@ -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`
+3
View File
@@ -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" }),
+8
View File
@@ -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,