feat(dashboard): add Debug Share to the System page (#38600)

* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml

When config.yaml fails to parse, load_config() silently falls back to
DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs
the setup wizard or hermes config set (both rewrite config.yaml), their
broken-but-recoverable overrides are lost for good.

Adapts the policy-file recovery from gemini-cli#21541: on the first parse
warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak
(best-effort, symlink-guarded, size-deduped) and tell the user where it
landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a
clean state — hermes never silently mutates user config, and leaving it means
a hand-fixed file is re-read on the next load.

Tests: 3 new cases (backup created + content preserved + original untouched;
same-size backup dedup; symlink not copied). E2E verified with isolated
HERMES_HOME and a real tab-indented broken config.

* feat(dashboard): add Debug Share to the System page

Surface `hermes debug share` in the dashboard. The System > Operations
section gets a dedicated card that uploads a redacted report + full logs
and returns the paste URLs as real, copyable links instead of a log tail.

- debug.py: factor a pure build_debug_share() returning structured
  {urls, failures, redacted, auto_delete_seconds}; run_debug_share now
  calls it (CLI output unchanged).
- web_server.py: POST /api/ops/debug-share runs the share core in a
  worker thread and returns the structured payload synchronously (the
  URLs are the whole point — not a backgrounded action).
- api.ts: runDebugShare() + DebugShareResponse.
- SystemPage.tsx: share card with a redaction toggle (on by default),
  per-link + copy-all buttons, and the 6h auto-delete countdown.
- tests: build_debug_share core + endpoint (redact toggle, failure 502,
  token gate).
This commit is contained in:
Teknium
2026-06-03 19:37:04 -07:00
committed by GitHub
parent f66a929a6b
commit e3313c50a7
8 changed files with 696 additions and 65 deletions

View File

@ -853,6 +853,15 @@ export const api = {
runDump: () => fetchJSON<ActionResponse>("/api/ops/dump", { method: "POST" }),
runConfigMigrate: () =>
fetchJSON<ActionResponse>("/api/ops/config-migrate", { method: "POST" }),
runDebugShare: (opts?: { redact?: boolean; lines?: number }) =>
fetchJSON<DebugShareResponse>("/api/ops/debug-share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
redact: opts?.redact ?? true,
lines: opts?.lines ?? 200,
}),
}),
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
@ -906,6 +915,16 @@ export interface ActionResponse {
update_command?: string;
}
export interface DebugShareResponse {
ok: boolean;
// label -> paste URL, e.g. { Report: "https://paste.rs/abc", "agent.log": "..." }
urls: Record<string, string>;
// "label: error" strings for optional full-log uploads that failed.
failures: string[];
redacted: boolean;
auto_delete_seconds: number;
}
export interface SessionStoreStats {
total: number;
active_store: number;

View File

@ -3,17 +3,22 @@ import { Link } from "react-router-dom";
import {
Activity,
Brain,
Check,
Clock,
Copy,
Cpu,
Database,
Download,
Globe,
HardDrive,
KeyRound,
Link2,
Play,
Plus,
Power,
RotateCw,
Server,
Share2,
ShieldCheck,
Sparkles,
Stethoscope,
@ -48,6 +53,7 @@ import type {
UpdateCheckResponse,
CuratorStatus,
PortalStatus,
DebugShareResponse,
} from "@/lib/api";
function formatBytes(n: number): string {
@ -324,6 +330,54 @@ export default function SystemPage() {
}
};
// ── Debug share ────────────────────────────────────────────────────
// Unlike the fire-and-forget ops above, `debug share` produces shareable
// paste URLs that are the whole point — so we surface them as real,
// copyable links rather than a log tail.
const [shareRedact, setShareRedact] = useState(true);
const [sharing, setSharing] = useState(false);
const [shareResult, setShareResult] = useState<DebugShareResponse | null>(
null,
);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
const copyToClipboard = useCallback(
async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(
() => setCopiedLabel((cur) => (cur === label ? null : cur)),
1500,
);
} catch {
showToast("Couldn't copy to clipboard", "error");
}
},
[showToast],
);
const runDebugShare = useCallback(async () => {
setSharing(true);
setShareResult(null);
try {
const res = await api.runDebugShare({ redact: shareRedact });
setShareResult(res);
const n = Object.keys(res.urls).length;
showToast(
`Uploaded ${n} paste${n === 1 ? "" : "s"}${
res.redacted ? " (redacted)" : ""
}`,
"success",
);
} catch (e) {
showToast(`Debug share failed: ${e}`, "error");
} finally {
setSharing(false);
}
}, [shareRedact, showToast]);
// ── Update check / apply ───────────────────────────────────────────
const checkForUpdate = useCallback(
async (force = false) => {
@ -992,6 +1046,129 @@ export default function SystemPage() {
</Button>
</CardContent>
</Card>
{/* Debug share — uploads a redacted report + logs, returns shareable
links. Separated from the buttons above because its output is
persistent, copyable URLs, not a fire-and-forget log tail. */}
<Card>
<CardContent className="flex flex-col gap-3 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-start gap-2">
<Share2 className="h-4 w-4 mt-0.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">Share debug report</span>
<span className="text-xs text-muted-foreground max-w-prose">
Uploads system info + logs to a public paste service and
returns links to send the Hermes team. Pastes auto-delete
after 6 hours.
</span>
</div>
</div>
<Button
size="sm"
disabled={sharing}
prefix={
sharing ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<Share2 className="h-3.5 w-3.5" />
)
}
onClick={() => void runDebugShare()}
>
{sharing ? "Uploading…" : "Generate share link"}
</Button>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground select-none">
<input
type="checkbox"
className="accent-current"
checked={shareRedact}
disabled={sharing}
onChange={(e) => setShareRedact(e.target.checked)}
/>
Redact credential-shaped tokens before upload (recommended)
</label>
{shareResult && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge tone="success">uploaded</Badge>
{shareResult.redacted ? (
<Badge tone="outline">redacted</Badge>
) : (
<Badge tone="warning">not redacted</Badge>
)}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
auto-deletes in{" "}
{Math.round(shareResult.auto_delete_seconds / 3600)}h
</span>
</div>
{Object.keys(shareResult.urls).length > 1 && (
<Button
size="sm"
ghost
prefix={
copiedLabel === "__all__" ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)
}
onClick={() =>
void copyToClipboard(
Object.entries(shareResult.urls)
.map(([label, url]) => `${label}: ${url}`)
.join("\n"),
"__all__",
)
}
>
Copy all
</Button>
)}
</div>
{Object.entries(shareResult.urls).map(([label, url]) => (
<div
key={label}
className="flex items-center gap-2 bg-background/50 border border-border px-3 py-2"
>
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono text-xs shrink-0 w-24 truncate text-muted-foreground">
{label}
</span>
<a
href={url}
target="_blank"
rel="noreferrer"
className="font-mono text-xs truncate flex-1 text-primary hover:underline"
>
{url}
</a>
<Button
ghost
size="icon"
aria-label={`Copy ${label} link`}
onClick={() => void copyToClipboard(url, label)}
>
{copiedLabel === label ? <Check /> : <Copy />}
</Button>
</div>
))}
{shareResult.failures.length > 0 && (
<span className="text-xs text-destructive">
Some logs failed to upload: {shareResult.failures.join("; ")}
</span>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
<div className="grid gap-2 flex-1">