feat: Report Bug button that creates Gitea issues

Adds a 'Report Bug' button to the web UI (top bar) that files an issue
on Gitea via the daemon. The issue body is enriched with auto-collected
context: nvcurve version, hostname, GPU name, applied offset summary,
active profile, and a collapsed tail of the recent server log.

Security model:
- The Gitea token (nvcurve_bot, scope write:issue) is embedded as a
  built-in default so the feature works out of the box for every
  install of this public repo — no configuration required.
- The token is only ever used server-side; it never reaches the
  browser, logs, or API responses.
- The endpoint is behind the existing session auth when enabled, and
  rate-limited to one report per 5 minutes per client IP.
- config.json can override gitea_url/gitea_repo/gitea_token (e.g.
  forks pointing at their own repo); gitea_token: '' disables it.
This commit is contained in:
ARIA committed 2026-10-03 21:06:50 +02:00
1 parent e89135ef94
commit 1bafbc80b5
6 files changed
+331 -2

No files matched your search

+26 -1
View File
@@ -7,10 +7,13 @@ import {
ChevronDown,
LogOut,
User,
Bug,
} from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore.js";
import { api } from "../../api/client.js";
import { ReportBugDialog } from "../common/ReportBugDialog.js";
import { useState, useRef, useEffect } from "react";
interface Props {
@@ -43,8 +46,17 @@ export function StatusBar({
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } =
useCurveStore();
const [isOpen, setIsOpen] = useState(false);
const [bugReportConfigured, setBugReportConfigured] = useState(false);
const [reportBugOpen, setReportBugOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api
.reportBugStatus()
.then((s) => setBugReportConfigured(s.configured))
.catch(() => {});
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
@@ -169,8 +181,18 @@ export function StatusBar({
/>
</div>
{/* Right: user + connection status */}
{/* Right: report bug + user + connection status */}
<div className="flex items-center gap-3 shrink-0">
{bugReportConfigured && (
<button
onClick={() => setReportBugOpen(true)}
title="Report a bug (creates a Gitea issue)"
className="flex items-center gap-1.5 px-2 py-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors"
>
<Bug size={14} />
<span className="hidden sm:inline text-xs">Report Bug</span>
</button>
)}
{user && (
<div className="flex items-center gap-1.5 text-zinc-400">
<User size={14} className="text-zinc-500" />
@@ -192,6 +214,9 @@ export function StatusBar({
</div>
</div>
</div>
{reportBugOpen && (
<ReportBugDialog onClose={() => setReportBugOpen(false)} />
)}
</header>
);
}
@@ -0,0 +1,120 @@
import { useState } from "react";
import { Bug, Loader } from "lucide-react";
import { toast } from "sonner";
import { api } from "../../api/client.js";
interface Props {
onClose: () => void;
}
export function ReportBugDialog({ onClose }: Props) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState<{
number: number | null;
url: string | null;
} | null>(null);
async function handleSubmit() {
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const res = await api.reportBug(title.trim(), body.trim());
setDone({ number: res.issue_number, url: res.issue_url });
toast.success(
res.issue_number != null ? `Issue #${res.issue_number} created` : "Issue created",
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create issue");
} finally {
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-[480px] max-w-[90vw]">
{done ? (
<>
<h2 className="text-zinc-100 font-semibold text-sm mb-2 flex items-center gap-2">
<Bug size={16} className="text-emerald-400" /> Issue created
</h2>
<p className="text-zinc-400 text-xs mb-4">
{done.number != null ? `Issue #${done.number} ` : "The issue "}
was filed on Gitea with the auto-collected system context.{" "}
{done.url ? (
<a
href={done.url}
target="_blank"
rel="noreferrer"
className="text-pink-400 hover:underline"
>
Open it →
</a>
) : (
"."
)}
</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-semibold transition-colors"
>
Close
</button>
</div>
</>
) : (
<>
<h2 className="text-zinc-100 font-semibold text-sm mb-1 flex items-center gap-2">
<Bug size={16} className="text-pink-400" /> Report a bug
</h2>
<p className="text-zinc-400 text-xs mb-4">
Creates a Gitea issue. System info (version, GPU, active profile,
recent log) is attached automatically.
</p>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title / Subject (required)"
maxLength={200}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-pink-500 mb-3"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="What happened? Steps to reproduce, expected vs. actual…"
rows={6}
maxLength={5000}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-pink-500 mb-4 resize-y"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={submitting}
className="px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!title.trim() || submitting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-pink-600 hover:bg-pink-500 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting && <Loader size={12} className="animate-spin" />}
Submit
</button>
</div>
</>
)}
</div>
</div>
);
}