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:
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user