hermes-agent/web/src/pages/SystemPage.tsx
Teknium e3313c50a7
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).
2026-06-03 19:37:04 -07:00

1259 lines
49 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
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,
Terminal,
Trash2,
X,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { cn, themedBody } from "@/lib/utils";
import { api } from "@/lib/api";
import type {
StatusResponse,
MemoryStatus,
CredentialPoolProvider,
CheckpointsResponse,
HooksResponse,
HookEntry,
SystemStats,
UpdateCheckResponse,
CuratorStatus,
PortalStatus,
DebugShareResponse,
} from "@/lib/api";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDuration(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/**
* Live action-log viewer for the spawn-based admin actions (doctor, audit,
* backup, import, skills update, checkpoints prune, gateway start/stop).
* Polls /api/actions/<name>/status until the process exits.
*/
function ActionLogViewer({
action,
onClose,
}: {
action: string;
onClose: () => void;
}) {
const [lines, setLines] = useState<string[]>([]);
const [running, setRunning] = useState(true);
const [exitCode, setExitCode] = useState<number | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const st = await api.getActionStatus(action, 400);
if (cancelled) return;
setLines(st.lines);
setRunning(st.running);
setExitCode(st.exit_code);
if (st.running) timer.current = setTimeout(poll, 1200);
} catch {
if (!cancelled) setRunning(false);
}
};
poll();
return () => {
cancelled = true;
if (timer.current) clearTimeout(timer.current);
};
}, [action]);
return (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{action}</span>
{running ? (
<Badge tone="warning">running</Badge>
) : (
<Badge tone={exitCode === 0 ? "success" : "destructive"}>
{exitCode === 0 ? "done" : `exit ${exitCode}`}
</Badge>
)}
</div>
<Button ghost size="icon" onClick={onClose} aria-label="Close log">
<X />
</Button>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-3 text-xs font-mono text-muted-foreground">
{lines.length ? lines.join("\n") : "Starting…"}
</pre>
</CardContent>
</Card>
);
}
const HOOK_EVENTS_FALLBACK = [
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
"on_session_start",
"on_session_end",
];
export default function SystemPage() {
const { toast, showToast } = useToast();
const [status, setStatus] = useState<StatusResponse | null>(null);
const [stats, setStats] = useState<SystemStats | null>(null);
const [memory, setMemory] = useState<MemoryStatus | null>(null);
const [pool, setPool] = useState<CredentialPoolProvider[]>([]);
const [checkpoints, setCheckpoints] = useState<CheckpointsResponse | null>(
null,
);
const [hooks, setHooks] = useState<HooksResponse | null>(null);
const [curator, setCurator] = useState<CuratorStatus | null>(null);
const [portal, setPortal] = useState<PortalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [activeAction, setActiveAction] = useState<string | null>(null);
// Add-credential form.
const [credProvider, setCredProvider] = useState("openrouter");
const [credKey, setCredKey] = useState("");
const [credLabel, setCredLabel] = useState("");
const [addingCred, setAddingCred] = useState(false);
const [importPath, setImportPath] = useState("");
// Create-hook modal.
const [hookModalOpen, setHookModalOpen] = useState(false);
const closeHookModal = useCallback(() => setHookModalOpen(false), []);
const hookModalRef = useModalBehavior({
open: hookModalOpen,
onClose: closeHookModal,
});
const [hookEvent, setHookEvent] = useState("pre_tool_call");
const [hookCommand, setHookCommand] = useState("");
const [hookMatcher, setHookMatcher] = useState("");
const [hookTimeout, setHookTimeout] = useState("");
const [hookApprove, setHookApprove] = useState(true);
const [creatingHook, setCreatingHook] = useState(false);
// ── Update check ───────────────────────────────────────────────────
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResponse | null>(
null,
);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
api.getSystemStats(),
api.getMemory(),
api.getCredentialPool(),
api.getCheckpoints(),
api.getHooks(),
api.getCurator(),
api.getPortal(),
// Cached (non-forced) check so the version row shows update status on
// load without a separate effect / a forced network round-trip.
api.checkHermesUpdate(false),
])
.then(([s, st, m, p, c, h, cur, prt, upd]) => {
if (s.status === "fulfilled") setStatus(s.value);
if (st.status === "fulfilled") setStats(st.value);
if (m.status === "fulfilled") setMemory(m.value);
if (p.status === "fulfilled") setPool(p.value.providers);
if (c.status === "fulfilled") setCheckpoints(c.value);
if (h.status === "fulfilled") setHooks(h.value);
if (cur.status === "fulfilled") setCurator(cur.value);
if (prt.status === "fulfilled") setPortal(prt.value);
if (upd.status === "fulfilled") setUpdateInfo(upd.value);
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
// ── Gateway lifecycle ──────────────────────────────────────────────
const runGateway = async (verb: "start" | "stop" | "restart") => {
try {
if (verb === "start") {
await api.startGateway();
setActiveAction("gateway-start");
} else if (verb === "stop") {
await api.stopGateway();
setActiveAction("gateway-stop");
} else {
await api.restartGateway();
setActiveAction("gateway-restart");
}
showToast(`Gateway ${verb} started`, "success");
setTimeout(loadAll, 3000);
} catch (e) {
showToast(`Gateway ${verb} failed: ${e}`, "error");
}
};
// ── Curator ────────────────────────────────────────────────────────
const toggleCuratorPaused = async () => {
if (!curator) return;
try {
await api.setCuratorPaused(!curator.paused);
showToast(curator.paused ? "Curator resumed" : "Curator paused", "success");
loadAll();
} catch (e) {
showToast(`Curator toggle failed: ${e}`, "error");
}
};
// ── Memory ─────────────────────────────────────────────────────────
// Memory provider selection lives on the /plugins page now (see the
// read-only display + link below); the dropdown was intentionally
// dropped from this card during the admin-panel refresh.
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
try {
const res = await api.resetMemory(
target as "all" | "memory" | "user",
);
showToast(`Reset: ${res.deleted.join(", ") || "nothing"}`, "success");
loadAll();
} catch (e) {
showToast(`Reset failed: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
// ── Credential pool ────────────────────────────────────────────────
const addCredential = async () => {
if (!credProvider.trim() || !credKey.trim()) {
showToast("Provider and API key required", "error");
return;
}
setAddingCred(true);
try {
await api.addCredentialPoolEntry(
credProvider.trim(),
credKey.trim(),
credLabel.trim() || undefined,
);
showToast("Credential added", "success");
setCredKey("");
setCredLabel("");
loadAll();
} catch (e) {
showToast(`Failed to add credential: ${e}`, "error");
} finally {
setAddingCred(false);
}
};
const credDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const [provider, idxStr] = key.split("|");
try {
await api.removeCredentialPoolEntry(provider, Number(idxStr));
showToast("Credential removed", "success");
loadAll();
} catch (e) {
showToast(`Failed to remove: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
// ── Operations ─────────────────────────────────────────────────────
const runOp = async (fn: () => Promise<{ name: string }>, label: string) => {
try {
const res = await fn();
setActiveAction(res.name);
showToast(`${label} started`, "success");
} catch (e) {
showToast(`${label} failed: ${e}`, "error");
}
};
// ── 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) => {
setCheckingUpdate(true);
try {
const info = await api.checkHermesUpdate(force);
setUpdateInfo(info);
if (force) {
if (info.update_available) {
showToast(
info.behind && info.behind > 0
? `Update available — ${info.behind} commit${info.behind === 1 ? "" : "s"} behind`
: "Update available",
"success",
);
} else if (info.behind === 0) {
showToast("You're on the latest version", "success");
} else if (info.message) {
showToast(info.message, "error");
}
}
} catch (e) {
showToast(`Update check failed: ${e}`, "error");
} finally {
setCheckingUpdate(false);
}
},
[showToast],
);
// Auto-check (cached) runs inside loadAll on mount; this is the
// user-triggered forced re-check from the "Check for updates" button.
const applyUpdate = async () => {
setUpdateConfirmOpen(false);
try {
const resp = await api.updateHermes();
if (!resp.ok && resp.error === "docker_update_unsupported") {
showToast(
resp.message ??
"Updates don't apply inside Docker — re-pull the image instead.",
"error",
);
return;
}
setActiveAction(resp.name ?? "hermes-update");
showToast("Update started", "success");
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
};
const checkpointsPrune = useConfirmDelete({
onDelete: useCallback(async () => {
try {
const res = await api.pruneCheckpoints();
setActiveAction(res.name);
showToast("Checkpoint prune started", "success");
} catch (e) {
showToast(`Prune failed: ${e}`, "error");
throw e;
}
}, [showToast]),
});
// ── Hooks ──────────────────────────────────────────────────────────
const createHook = async () => {
if (!hookCommand.trim()) {
showToast("Command is required", "error");
return;
}
setCreatingHook(true);
try {
await api.createHook({
event: hookEvent,
command: hookCommand.trim(),
matcher: hookMatcher.trim() || undefined,
timeout: hookTimeout.trim() ? Number(hookTimeout) : undefined,
approve: hookApprove,
});
showToast("Hook created", "success");
setHookCommand("");
setHookMatcher("");
setHookTimeout("");
setHookModalOpen(false);
loadAll();
} catch (e) {
showToast(`Failed to create hook: ${e}`, "error");
} finally {
setCreatingHook(false);
}
};
const hookDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const sep = key.indexOf("|");
const event = key.slice(0, sep);
const command = key.slice(sep + 1);
try {
await api.deleteHook(event, command);
showToast("Hook removed", "success");
loadAll();
} catch (e) {
showToast(`Failed to remove hook: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const gatewayRunning = status?.gateway_running;
const validEvents = hooks?.valid_events?.length
? hooks.valid_events
: HOOK_EVENTS_FALLBACK;
return (
<div className="flex flex-col gap-8">
<Toast toast={toast} />
<ConfirmDialog
open={updateConfirmOpen}
onCancel={() => setUpdateConfirmOpen(false)}
onConfirm={() => void applyUpdate()}
title="Update Hermes?"
description={
updateInfo && updateInfo.behind && updateInfo.behind > 0
? `This will run 'hermes update' (${updateInfo.update_command}) and pull ${updateInfo.behind} new commit${updateInfo.behind === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.`
: `This will run 'hermes update' (${updateInfo?.update_command ?? "hermes update"}) and restart the gateway when it finishes.`
}
confirmLabel="Update now"
/>
<DeleteConfirmDialog
open={memoryReset.isOpen}
onCancel={memoryReset.cancel}
onConfirm={memoryReset.confirm}
title="Reset memory"
description="This permanently erases the selected built-in memory files. This cannot be undone."
loading={memoryReset.isDeleting}
/>
<DeleteConfirmDialog
open={credDelete.isOpen}
onCancel={credDelete.cancel}
onConfirm={credDelete.confirm}
title="Remove credential"
description="Remove this pooled API key? The agent will no longer rotate through it."
loading={credDelete.isDeleting}
/>
<DeleteConfirmDialog
open={checkpointsPrune.isOpen}
onCancel={checkpointsPrune.cancel}
onConfirm={checkpointsPrune.confirm}
title="Prune checkpoints"
description="Delete the rollback checkpoint shadow store? Existing /rollback points will be lost."
loading={checkpointsPrune.isDeleting}
/>
<DeleteConfirmDialog
open={hookDelete.isOpen}
onCancel={hookDelete.cancel}
onConfirm={hookDelete.confirm}
title="Remove shell hook"
description="Remove this hook from config and revoke its consent? It stops firing on the next restart."
loading={hookDelete.isDeleting}
/>
{/* Create-hook modal */}
{hookModalOpen && (
<div
ref={hookModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setHookModalOpen(false)}
role="dialog"
aria-modal="true"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setHookModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2 className="font-mondwest text-display text-base tracking-wider">
New shell hook
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="hook-event">Event</Label>
<Select
id="hook-event"
value={hookEvent}
onValueChange={(v) => setHookEvent(v)}
>
{validEvents.map((ev) => (
<SelectOption key={ev} value={ev}>
{ev}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="hook-command">Command (absolute path)</Label>
<Input
id="hook-command"
autoFocus
placeholder="/usr/local/bin/my-hook.sh"
value={hookCommand}
onChange={(e) => setHookCommand(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="hook-matcher">Matcher (optional)</Label>
<Input
id="hook-matcher"
placeholder="e.g. terminal"
value={hookMatcher}
onChange={(e) => setHookMatcher(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="hook-timeout">Timeout (s)</Label>
<Input
id="hook-timeout"
placeholder="10"
value={hookTimeout}
onChange={(e) => setHookTimeout(e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={hookApprove}
onChange={(e) => setHookApprove(e.target.checked)}
/>
Approve now (grant consent so it fires; otherwise it stays
configured but inactive)
</label>
<p className="text-xs text-warning">
Shell hooks run arbitrary commands on this host. Only add scripts
you trust. Takes effect on the next gateway/session restart.
</p>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={createHook}
disabled={creatingHook}
prefix={creatingHook ? <Spinner /> : undefined}
>
{creatingHook ? "Creating" : "Create hook"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Live action log */}
{activeAction && (
<ActionLogViewer
action={activeAction}
onClose={() => setActiveAction(null)}
/>
)}
{/* ── Host / system stats ───────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Server className="h-4 w-4" /> Host
</H2>
<Card>
<CardContent className="py-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-y-3 gap-x-6 text-sm">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">OS</div>
<div>{stats?.os} {stats?.os_release}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Arch</div>
<div>{stats?.arch}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Host</div>
<div className="truncate">{stats?.hostname}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Python</div>
<div>{stats?.python_impl} {stats?.python_version}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
<div className="flex items-center gap-2">
<span>v{stats?.hermes_version}</span>
{updateInfo &&
(updateInfo.update_available ? (
<Badge tone="warning">
{updateInfo.behind && updateInfo.behind > 0
? `${updateInfo.behind} behind`
: "update available"}
</Badge>
) : updateInfo.behind === 0 ? (
<Badge tone="success">latest</Badge>
) : null)}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</div>
<div>
{stats?.cpu_count ?? "—"} cores
{typeof stats?.cpu_percent === "number"
? ` · ${stats.cpu_percent.toFixed(0)}%`
: ""}
</div>
</div>
{stats?.memory && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Memory</div>
<div>
{formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)} ({stats.memory.percent}%)
</div>
</div>
)}
{stats?.disk && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Disk
</div>
<div>
{formatBytes(stats.disk.used)} / {formatBytes(stats.disk.total)} ({stats.disk.percent}%)
</div>
</div>
)}
{typeof stats?.uptime_seconds === "number" && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Uptime</div>
<div>{formatDuration(stats.uptime_seconds)}</div>
</div>
)}
{stats?.load_avg && stats.load_avg.length >= 3 && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Load avg</div>
<div>{stats.load_avg.map((n) => n.toFixed(2)).join(" / ")}</div>
</div>
)}
</div>
{stats && !stats.psutil && (
<p className="mt-3 text-xs text-muted-foreground">
Install the <span className="font-mono">psutil</span> extra for
CPU / memory / disk metrics.
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
size="sm"
ghost
disabled={checkingUpdate}
prefix={
checkingUpdate ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)
}
onClick={() => void checkForUpdate(true)}
>
Check for updates
</Button>
{updateInfo?.update_available && updateInfo.can_apply && (
<Button
size="sm"
prefix={<Download className="h-3.5 w-3.5" />}
onClick={() => setUpdateConfirmOpen(true)}
>
Update now
</Button>
)}
{updateInfo &&
!updateInfo.can_apply &&
updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
Update with{" "}
<span className="font-mono">{updateInfo.update_command}</span>
</span>
)}
{updateInfo?.message && !updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
{updateInfo.message}
</span>
)}
</div>
</CardContent>
</Card>
</section>
{/* ── Portal ────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4" /> Nous Portal
</H2>
<Card>
<CardContent className="flex flex-col gap-3 py-4">
<div className="flex items-center gap-3">
<Badge tone={portal?.logged_in ? "success" : "secondary"}>
{portal?.logged_in ? "logged in" : "not logged in"}
</Badge>
{portal?.provider && (
<span className="text-sm text-muted-foreground">
inference provider: {portal.provider}
</span>
)}
<a
href={portal?.subscription_url || "https://portal.nousresearch.com/manage-subscription"}
target="_blank"
rel="noreferrer"
className="ml-auto text-xs text-primary underline"
>
Manage subscription
</a>
</div>
{portal?.features && portal.features.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
Tool Gateway routing
</span>
{portal.features.map((f) => (
<div key={f.label} className="flex items-center justify-between text-sm">
<span>{f.label}</span>
<span className="text-muted-foreground">{f.state}</span>
</div>
))}
</div>
)}
{!portal?.logged_in && (
<p className="text-xs text-muted-foreground">
Log in with <span className="font-mono">hermes portal</span>.
</p>
)}
</CardContent>
</Card>
</section>
{/* ── Curator ───────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="h-4 w-4" /> Skill curator
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Badge tone={curator?.paused ? "warning" : curator?.enabled ? "success" : "secondary"}>
{curator?.paused ? "paused" : curator?.enabled ? "active" : "disabled"}
</Badge>
<span className="text-sm text-muted-foreground">
{curator?.interval_hours ? `every ${curator.interval_hours}h` : ""}
{curator?.last_run_at ? ` · last run ${new Date(curator.last_run_at).toLocaleString()}` : " · never run"}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" ghost onClick={toggleCuratorPaused}>
{curator?.paused ? "Resume" : "Pause"}
</Button>
<Button
size="sm"
ghost
prefix={<Play className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runCurator, "Curator review")}
>
Run now
</Button>
</div>
</CardContent>
</Card>
</section>
{/* ── Gateway ───────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Power className="h-4 w-4" /> Gateway
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Badge tone={gatewayRunning ? "success" : "secondary"}>
{gatewayRunning ? "running" : "stopped"}
</Badge>
<span className="text-sm text-muted-foreground">
{status?.gateway_state ?? "—"}
{status?.gateway_pid ? ` · pid ${status.gateway_pid}` : ""}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => runGateway("start")}
disabled={gatewayRunning}
prefix={<Play className="h-3.5 w-3.5" />}
>
Start
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => runGateway("restart")}
prefix={<RotateCw className="h-3.5 w-3.5" />}
>
Restart
</Button>
<Button
size="sm"
className="uppercase text-warning"
ghost
onClick={() => runGateway("stop")}
disabled={!gatewayRunning}
prefix={<Power className="h-3.5 w-3.5" />}
>
Stop
</Button>
</div>
</CardContent>
</Card>
</section>
{/* ── Memory ────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Brain className="h-4 w-4" /> Memory
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
External provider:{" "}
<span className="font-mono text-foreground">
{memory?.active || "built-in only"}
</span>
</span>
<Link to="/plugins" className="underline">
Change in Plugins
</Link>
<span className="ml-auto">
New credentials:{" "}
<span className="font-mono">hermes memory setup</span>
</span>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
{formatBytes(memory?.builtin_files.memory ?? 0)} · USER.md:{" "}
{formatBytes(memory?.builtin_files.user ?? 0)}
</span>
<div className="flex items-center gap-2 ml-auto">
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("memory")}>
Reset MEMORY.md
</Button>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("user")}>
Reset USER.md
</Button>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("all")}>
Reset all
</Button>
</div>
</div>
</CardContent>
</Card>
</section>
{/* ── Credential pool ───────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<KeyRound className="h-4 w-4" /> Credential pool
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
<div className="grid gap-2">
<Label htmlFor="cred-provider">Provider</Label>
<Input id="cred-provider" value={credProvider} onChange={(e) => setCredProvider(e.target.value)} placeholder="openrouter" />
</div>
<div className="grid gap-2 sm:col-span-2">
<Label htmlFor="cred-key">API key</Label>
<Input id="cred-key" type="password" value={credKey} onChange={(e) => setCredKey(e.target.value)} placeholder="sk-…" />
</div>
<div className="grid gap-2">
<Label htmlFor="cred-label">Label</Label>
<Input id="cred-label" value={credLabel} onChange={(e) => setCredLabel(e.target.value)} placeholder="optional" />
</div>
</div>
<div className="flex justify-end">
<Button size="sm" className="uppercase" onClick={addCredential} disabled={addingCred} prefix={addingCred ? <Spinner /> : undefined}>
Add key
</Button>
</div>
{pool.length === 0 && (
<p className="text-sm text-muted-foreground">
No pooled credentials. Add one above to enable key rotation.
</p>
)}
{pool.map((prov) => (
<div key={prov.provider} className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{prov.provider}
</span>
{prov.entries.map((entry) => (
<div key={`${prov.provider}-${entry.index}`} className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="font-mono text-xs text-muted-foreground">{entry.token_preview}</span>
<Badge tone="outline">{entry.auth_type}</Badge>
{entry.last_status && <Badge tone="secondary">{entry.last_status}</Badge>}
<Button ghost size="icon" className="ml-auto text-destructive" aria-label="Remove credential" onClick={() => credDelete.requestDelete(`${prov.provider}|${entry.index}`)}>
<Trash2 />
</Button>
</div>
))}
</div>
))}
</CardContent>
</Card>
</section>
{/* ── Operations ────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Activity className="h-4 w-4" /> Operations
</H2>
<Card>
<CardContent className="flex flex-wrap gap-2 py-4">
<Button size="sm" ghost prefix={<Stethoscope className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDoctor, "Doctor")}>
Run doctor
</Button>
<Button size="sm" ghost prefix={<ShieldCheck className="h-3.5 w-3.5" />} onClick={() => runOp(api.runSecurityAudit, "Security audit")}>
Security audit
</Button>
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(() => api.runBackup(), "Backup")}>
Create backup
</Button>
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}>
Update skills
</Button>
<Button size="sm" ghost prefix={<Activity className="h-3.5 w-3.5" />} onClick={() => runOp(api.runPromptSize, "Prompt size")}>
Prompt size
</Button>
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDump, "Support dump")}>
Support dump
</Button>
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.runConfigMigrate, "Config migrate")}>
Migrate config
</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">
<Label htmlFor="import-path">Restore from backup archive</Label>
<Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" />
</div>
<Button
size="sm"
ghost
disabled={!importPath.trim()}
onClick={() => {
if (!importPath.trim()) return;
runOp(() => api.runImport(importPath.trim()), "Import");
}}
>
Import
</Button>
</CardContent>
</Card>
</section>
{/* ── Checkpoints ───────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Database className="h-4 w-4" /> Checkpoints
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="text-sm text-muted-foreground">
{checkpoints?.sessions.length ?? 0} session(s) ·{" "}
{formatBytes(checkpoints?.total_bytes ?? 0)}
</span>
<Button size="sm" ghost className="text-destructive" disabled={!checkpoints?.sessions.length} prefix={<Trash2 className="h-3.5 w-3.5" />} onClick={() => checkpointsPrune.requestDelete("all")}>
Prune
</Button>
</CardContent>
</Card>
</section>
{/* ── Shell hooks ───────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Terminal className="h-4 w-4" /> Shell hooks
</H2>
<Button size="sm" className="uppercase" prefix={<Plus className="h-3.5 w-3.5" />} onClick={() => setHookModalOpen(true)}>
New hook
</Button>
</div>
{(!hooks || hooks.hooks.length === 0) && (
<Card>
<CardContent className="py-6 text-center text-sm text-muted-foreground">
No shell hooks configured.
</CardContent>
</Card>
)}
{hooks?.hooks.map((h: HookEntry, i) => (
<Card key={`${h.event}-${i}`}>
<CardContent className="flex items-center gap-3 py-3">
<Badge tone="outline">{h.event}</Badge>
{h.matcher && (
<span className="text-xs text-muted-foreground">matcher: {h.matcher}</span>
)}
<span className="font-mono text-xs truncate flex-1">{h.command}</span>
{h.executable === false && (
<Badge tone="destructive">not executable</Badge>
)}
<Badge tone={h.allowed ? "success" : "warning"}>
{h.allowed ? "allowed" : "not approved"}
</Badge>
<Button
ghost
size="icon"
className="text-destructive"
aria-label="Remove hook"
onClick={() =>
hookDelete.requestDelete(`${h.event}|${h.command ?? ""}`)
}
>
<Trash2 />
</Button>
</CardContent>
</Card>
))}
</section>
</div>
);
}