mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
* 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).
1259 lines
49 KiB
TypeScript
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>
|
|
);
|
|
}
|