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:
@@ -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