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
+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,