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

+8
View File
@@ -185,4 +185,12 @@ export const api = {
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number, fan?: number | null) =>
post("/fans/speed", { fan_pct: fanPct, fan: fan ?? null }, gpuIndex),
/** Report Bug (Gitea) */
reportBugStatus: () => get<{ configured: boolean }>("/report-bug/status"),
reportBug: (title: string, body: string) =>
post<{ ok: boolean; issue_number: number | null; issue_url: string | null }>(
"/report-bug",
{ title, body },
),
};
+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>
);
}
+3
View File
@@ -2309,6 +2309,9 @@ def main():
"ssl_certfile",
"ssl_keyfile",
"allow_api_shutdown",
"gitea_url",
"gitea_repo",
"gitea_token",
):
if key in data:
setattr(cfg, key, data[key])
+14
View File
@@ -59,6 +59,20 @@ class Config:
# Key = stable GPU identifier (same as auto_load_profiles).
power_cap_modes: dict[str, str] = field(default_factory=dict)
# Gitea "Report Bug" integration: the built-in defaults point at the
# upstream project's Gitea, so the feature works out of the box for every
# install — no configuration required. Forks can override these in
# config.json (own instance/repo/token); setting gitea_token to ""
# disables the feature. The token is scoped to write:issue on a public
# repo, so it can only create/read issues there.
gitea_url: str = "https://gitea.zephyre.one"
gitea_repo: str = "Pakobbix/nvcurve"
# Intentionally embedded: zero-config bug reporting for a public repo.
# The token can only create/read issues on Pakobbix/nvcurve (scope
# write:issue) and is revocable/rotatable by the repo owner.
# pi-lens-ignore: S105
gitea_token: str = "bfe416043b633f8f3c4df103272ad0ccf91c8f22"
# Module-level default config instance.
default_config = Config()
+160 -1
View File
@@ -10,16 +10,20 @@ import asyncio
import logging
import os
import signal
import socket
import time
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any, Protocol
import httpx
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import __version__
from . import auth
from .config import Config, default_config, tls_enabled
from .hal.dashboard import get_dashboard_info
@@ -848,6 +852,11 @@ class ProcessKillRequest(BaseModel):
parent: bool = False
class ReportBugRequest(BaseModel):
title: str
body: str = ""
# ── Helper: run blocking HAL call in thread pool ──────────────────────────────
@@ -2141,6 +2150,157 @@ async def api_shutdown():
return {"ok": True}
# ── Report Bug (Gitea) ────────────────────────────────────────────────────────
# The Gitea token is held server-side only and never reaches the browser.
# Recommended setup: a dedicated bot user with write access to just the target
# repo and a token scoped to write:issue, so a leaked token cannot read code
# or push.
_BUG_REPORT_COOLDOWN_S = 300.0 # per-client rate limit
_bug_report_last: dict[str, float] = {}
_SERVER_LOG_FILE = "/var/log/nvcurve-server.log"
@app.get("/api/report-bug/status", tags=["Report Bug"], responses={**R_AUTH})
async def api_report_bug_status():
"""Whether the Report Bug feature is configured (token set server-side)."""
cfg: Config = _state["config"]
return {
"configured": bool(cfg.gitea_url and cfg.gitea_repo and cfg.gitea_token)
}
def _build_bug_report_body(body: str) -> str:
"""Compose the Gitea issue body: user report + auto-collected context."""
lines: list[str] = []
if body:
lines.append(body)
lines.append("")
lines.append("---")
lines.append("_Reported via the NVCurve web UI.")
lines.append("")
lines.append("## System context (auto-collected)")
lines.append("")
lines.append(f"- nvcurve version: {__version__}")
lines.append(f"- hostname: {socket.gethostname()}")
for gpu_index, g_state in sorted(_state["gpus"].items()):
name = g_state.get("gpu_name") or f"GPU {gpu_index}"
lines.append(f"- GPU {gpu_index}: {name}")
if g_state.get("last_offsets"):
offsets = g_state["last_offsets"]
non_zero = [(i, v) for i, v in enumerate(offsets) if v]
if non_zero:
vals = [v for _, v in non_zero]
lines.append(
f" - last applied offsets: {len(non_zero)} non-zero points "
f"(min {min(vals)} kHz, max {max(vals)} kHz)"
)
else:
lines.append(" - last applied offsets: none (all zero)")
if g_state.get("active_profile"):
lines.append(f" - active profile: {g_state['active_profile']}")
# Best-effort tail of the server log (daemon mode writes to this file).
try:
with open(_SERVER_LOG_FILE, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
f.seek(max(0, size - 8192))
tail = f.read().decode("utf-8", errors="replace")
log_lines = tail.splitlines()[-40:]
if log_lines:
lines.append("")
lines.append("<details><summary>Recent server log (last 40 lines)</summary>")
lines.append("")
lines.append("```")
lines.extend(log_lines)
lines.append("```")
lines.append("</details>")
except OSError:
pass
return "\n".join(lines)
@app.post(
"/api/report-bug",
tags=["Report Bug"],
responses={**R_AUTH, 429: {"description": "Rate limit: report too soon."}, 502: {"description": "Gitea unreachable or token rejected."}, 503: {"description": "Not configured."}},
)
async def api_report_bug(req: ReportBugRequest, request: Request):
"""Create a Gitea issue from a bug report.
Auto-collected context (version, GPUs, active profile, recent log tail)
is appended to the issue body. The Gitea token never leaves the server.
"""
cfg: Config = _state["config"]
if not (cfg.gitea_url and cfg.gitea_repo and cfg.gitea_token):
raise HTTPException(
status_code=503,
detail="Report Bug is not configured (gitea_url/gitea_repo/gitea_token)",
)
title = req.title.strip()[:200]
if not title:
raise HTTPException(status_code=422, detail="Title is required")
body = req.body.strip()[:5000]
# Rate limit: one report per client per cooldown window.
now = time.monotonic()
try:
key = _client_ip(request, cfg.trusted_proxies)
except Exception:
key = "unknown"
for stale in [k for k, t in _bug_report_last.items() if now - t > _BUG_REPORT_COOLDOWN_S * 2]:
del _bug_report_last[stale]
last = _bug_report_last.get(key)
if last is not None and now - last < _BUG_REPORT_COOLDOWN_S:
wait = int(_BUG_REPORT_COOLDOWN_S - (now - last))
raise HTTPException(
status_code=429, detail=f"Too soon — try again in {wait}s"
)
_bug_report_last[key] = now
url = f"{cfg.gitea_url.rstrip('/')}/api/v1/repos/{cfg.gitea_repo}/issues"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
res = await client.post(
url,
json={"title": title, "body": _build_bug_report_body(body)},
headers={"Authorization": f"token {cfg.gitea_token}"},
)
except httpx.HTTPError as exc:
log.warning("Report Bug: Gitea request failed: %s", exc)
raise HTTPException(
status_code=502, detail="Could not reach Gitea"
) from exc
if res.status_code in (401, 403):
log.warning(
"Report Bug: Gitea rejected the token (HTTP %d)", res.status_code
)
raise HTTPException(
status_code=502,
detail="Gitea rejected the configured token (invalid or insufficient scope)",
)
if res.status_code >= 400:
log.warning(
"Report Bug: Gitea error HTTP %d: %s", res.status_code, res.text[:300]
)
raise HTTPException(
status_code=502, detail=f"Gitea error (HTTP {res.status_code})"
)
data = res.json()
log.info(
"Report Bug: created issue #%s (%s)",
data.get("number"),
data.get("html_url"),
)
return {
"ok": True,
"issue_number": data.get("number"),
"issue_url": data.get("html_url"),
}
@app.post("/api/snapshot/save", tags=["Snapshots"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_snapshot_save(gpu_index: int = 0):
"""Save a ClockBoostTable snapshot."""
@@ -2409,7 +2569,6 @@ def run(
When both ssl_certfile and ssl_keyfile are given (either here or in the
config), the server serves HTTPS and the session cookie is Secure.
"""
import socket
import threading
import uvicorn