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:
1 parent
e89135ef94
commit
1bafbc80b5
6 files changed
+331
-2
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user