feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)

* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
This commit is contained in:
Teknium
2026-06-02 00:16:11 -04:00
committed by GitHub
parent 40ae170647
commit bd8e2ec1a6
20 changed files with 2597 additions and 210 deletions
+202 -1
View File
@@ -238,6 +238,24 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
},
),
getSessionStats: () => fetchJSON<SessionStoreStats>("/api/sessions/stats"),
exportSessionUrl: (id: string) =>
`/api/sessions/${encodeURIComponent(id)}/export`,
pruneSessions: (older_than_days: number, source?: string) =>
fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
@@ -311,6 +329,19 @@ export const api = {
}),
pauseCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
updateCronJob: (
id: string,
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
profile = "default",
) =>
fetchJSON<CronJob>(
`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
},
),
resumeCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
triggerCronJob: (id: string, profile = "default") =>
@@ -522,6 +553,32 @@ export const api = {
`/api/mcp/servers/${encodeURIComponent(name)}/test`,
{ method: "POST" },
),
setMcpServerEnabled: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
),
getMcpCatalog: () =>
fetchJSON<{ entries: McpCatalogEntry[]; diagnostics: McpCatalogDiagnostic[] }>(
"/api/mcp/catalog",
),
installMcpCatalogEntry: (
name: string,
env: Record<string, string> = {},
enable = true,
) =>
fetchJSON<{ ok: boolean; name: string; background: boolean; action?: string }>(
"/api/mcp/catalog/install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, env, enable }),
},
),
// ── Admin: Pairing ──────────────────────────────────────────────────
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
@@ -554,6 +611,15 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
setWebhookEnabled: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/webhooks/${encodeURIComponent(name)}/enabled`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
),
// ── Admin: Credential pool ──────────────────────────────────────────
getCredentialPool: () =>
@@ -616,6 +682,45 @@ export const api = {
body: JSON.stringify({ archive }),
}),
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
createHook: (body: HookCreate) =>
fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>(
"/api/ops/hooks",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
deleteHook: (event: string, command: string) =>
fetchJSON<{ ok: boolean }>("/api/ops/hooks", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event, command }),
}),
getSystemStats: () => fetchJSON<SystemStats>("/api/system/stats"),
// ── Admin: Curator ──────────────────────────────────────────────────
getCurator: () => fetchJSON<CuratorStatus>("/api/curator"),
setCuratorPaused: (paused: boolean) =>
fetchJSON<{ ok: boolean; paused: boolean }>("/api/curator/paused", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paused }),
}),
runCurator: () =>
fetchJSON<ActionResponse>("/api/curator/run", { method: "POST" }),
// ── Admin: Portal ───────────────────────────────────────────────────
getPortal: () => fetchJSON<PortalStatus>("/api/portal"),
// ── Admin: Diagnostics (backgrounded) ───────────────────────────────
runPromptSize: () =>
fetchJSON<ActionResponse>("/api/ops/prompt-size", { method: "POST" }),
runDump: () => fetchJSON<ActionResponse>("/api/ops/dump", { method: "POST" }),
runConfigMigrate: () =>
fetchJSON<ActionResponse>("/api/ops/config-migrate", { method: "POST" }),
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
pruneCheckpoints: () =>
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
@@ -635,6 +740,10 @@ export const api = {
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
fetchJSON<{ results: SkillHubResult[] }>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
),
};
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
@@ -663,6 +772,24 @@ export interface ActionResponse {
update_command?: string;
}
export interface SessionStoreStats {
total: number;
active_store: number;
archived: number;
messages: number;
by_source: Record<string, number>;
}
export interface SkillHubResult {
name: string;
description: string;
source: string;
identifier: string;
trust_level: string;
repo: string | null;
tags: string[];
}
// ── Admin types ───────────────────────────────────────────────────────
export interface McpServer {
@@ -677,6 +804,25 @@ export interface McpServer {
tools: string[] | null;
}
export interface McpCatalogEntry {
name: string;
description: string;
source: string;
transport: "http" | "stdio";
auth_type: "api_key" | "oauth" | "none";
required_env: Array<{ name: string; prompt: string; required: boolean }>;
needs_install: boolean;
installed: boolean;
enabled: boolean;
}
export interface McpCatalogDiagnostic {
name: string;
kind: string;
message: string;
}
export interface McpServerCreate {
name: string;
url?: string;
@@ -716,6 +862,7 @@ export interface WebhookRoute {
created_at: string | null;
url: string;
secret_set: boolean;
enabled: boolean;
}
export interface WebhooksResponse {
@@ -771,11 +918,65 @@ export interface HookEntry {
command: string | null;
timeout: number | null;
allowed: boolean;
approved_at?: string | null;
executable?: boolean;
}
export interface HooksResponse {
hooks: HookEntry[];
allowlist: string[];
valid_events: string[];
}
export interface HookCreate {
event: string;
command: string;
matcher?: string;
timeout?: number;
approve?: boolean;
}
export interface SystemStats {
os: string;
os_release: string;
os_version: string;
platform: string;
arch: string;
hostname: string;
python_version: string;
python_impl: string;
hermes_version: string;
cpu_count: number | null;
psutil: boolean;
cpu_percent?: number;
load_avg?: number[];
uptime_seconds?: number;
memory?: { total: number; available: number; used: number; percent: number };
disk?: { total: number; used: number; free: number; percent: number };
process?: { pid: number; rss: number; create_time: number; num_threads: number };
}
export interface CuratorStatus {
enabled: boolean;
paused: boolean;
interval_hours: number | null;
last_run_at: string | null;
min_idle_hours: number | null;
stale_after_days: number | null;
archive_after_days: number | null;
}
export interface PortalFeature {
label: string;
state: string;
}
export interface PortalStatus {
logged_in: boolean;
portal_url: string | null;
inference_url: string | null;
provider: string;
subscription_url: string;
features: PortalFeature[];
}
export interface CheckpointSession {
+168 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
import { Clock, Pause, Pencil, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@@ -119,6 +119,29 @@ export default function CronPage() {
const [creating, setCreating] = useState(false);
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
// Edit job modal state
const [editJob, setEditJob] = useState<CronJob | null>(null);
const [editPrompt, setEditPrompt] = useState("");
const [editSchedule, setEditSchedule] = useState("");
const [editName, setEditName] = useState("");
const [editDeliver, setEditDeliver] = useState("local");
const [saving, setSaving] = useState(false);
const closeEditModal = useCallback(() => setEditJob(null), []);
const editModalRef = useModalBehavior({
open: editJob !== null,
onClose: closeEditModal,
});
const openEditModal = useCallback((job: CronJob) => {
setEditJob(job);
setEditPrompt(getJobPrompt(job));
setEditSchedule(
asText(job.schedule?.expr) || asText(job.schedule_display) || "",
);
setEditName(getJobName(job));
setEditDeliver(asText(job.deliver) || "local");
}, []);
const loadJobs = useCallback(() => {
api
.getCronJobs(selectedProfile)
@@ -168,6 +191,34 @@ export default function CronPage() {
}
};
const handleEdit = async () => {
if (!editJob) return;
if (!editPrompt.trim() || !editSchedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setSaving(true);
try {
await api.updateCronJob(
editJob.id,
{
prompt: editPrompt.trim(),
schedule: editSchedule.trim(),
name: editName.trim(),
deliver: editDeliver,
},
getJobProfile(editJob),
);
showToast("Saved changes ✓", "success");
setEditJob(null);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = getJobState(job) === "paused";
@@ -394,6 +445,112 @@ export default function CronPage() {
</div>
)}
{/* Edit job modal */}
{editJob && (
<div
ref={editModalRef}
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 && setEditJob(null)}
role="dialog"
aria-modal="true"
aria-labelledby="edit-cron-title"
>
<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={() => setEditJob(null)}
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
id="edit-cron-title"
className="font-mondwest text-display text-base tracking-wider"
>
Edit job
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-name">{t.cron.nameOptional}</Label>
<Input
id="edit-cron-name"
autoFocus
placeholder={t.cron.namePlaceholder}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-prompt">{t.cron.prompt}</Label>
<textarea
id="edit-cron-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={t.cron.promptPlaceholder}
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-schedule">{t.cron.schedule}</Label>
<Input
id="edit-cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={editSchedule}
onChange={(e) => setEditSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="edit-cron-deliver"
value={editDeliver}
onValueChange={(v) => setEditDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleEdit}
disabled={saving}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? t.common.loading : "Save changes"}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@@ -501,6 +658,16 @@ export default function CronPage() {
<Zap />
</Button>
<Button
ghost
size="icon"
title="Edit job"
aria-label="Edit job"
onClick={() => openEditModal(job)}
>
<Pencil />
</Button>
<Button
ghost
destructive
+325 -14
View File
@@ -1,12 +1,18 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Server, Trash2, X, Zap } from "lucide-react";
import { Package, Power, Server, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { McpServer, McpServerCreate, McpTestResult } from "@/lib/api";
import type {
McpCatalogDiagnostic,
McpCatalogEntry,
McpServer,
McpServerCreate,
McpTestResult,
} from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@@ -55,6 +61,8 @@ const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
export default function McpPage() {
const [servers, setServers] = useState<McpServer[]>([]);
const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
@@ -80,17 +88,44 @@ export default function McpPage() {
Record<string, McpTestResult>
>({});
// Enable/disable state
const [togglingName, setTogglingName] = useState<string | null>(null);
const [restartNote, setRestartNote] = useState<string | null>(null);
// Catalog install modal state
const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
null,
);
const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
const [installingName, setInstallingName] = useState<string | null>(null);
const closeInstallModal = useCallback(() => setInstallEntry(null), []);
const installModalRef = useModalBehavior({
open: installEntry !== null,
onClose: closeInstallModal,
});
const loadServers = useCallback(() => {
api
return api
.getMcpServers()
.then((res) => setServers(res.servers))
.catch((e) => showToast(`Error: ${e}`, "error"))
.finally(() => setLoading(false));
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
const loadCatalog = useCallback(() => {
return api
.getMcpCatalog()
.then((res) => {
setCatalog(res.entries);
setDiagnostics(res.diagnostics);
})
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
useEffect(() => {
loadServers();
}, [loadServers]);
Promise.all([loadServers(), loadCatalog()]).finally(() =>
setLoading(false),
);
}, [loadServers, loadCatalog]);
const handleCreate = async () => {
if (!name.trim()) {
@@ -141,10 +176,7 @@ export default function McpPage() {
const result = await api.testMcpServer(server.name);
setTestResults((prev) => ({ ...prev, [server.name]: result }));
if (result.ok) {
showToast(
`${server.name}: ${result.tools.length} tool(s)`,
"success",
);
showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
} else {
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
}
@@ -155,6 +187,26 @@ export default function McpPage() {
}
};
const handleToggleEnabled = async (server: McpServer) => {
const next = !server.enabled;
setTogglingName(server.name);
try {
await api.setMcpServerEnabled(server.name, next);
setServers((prev) =>
prev.map((s) =>
s.name === server.name ? { ...s, enabled: next } : s,
),
);
setRestartNote(
"Enable/disable takes effect on the next gateway restart.",
);
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
};
const serverDelete = useConfirmDelete({
onDelete: useCallback(
async (serverName: string) => {
@@ -176,6 +228,58 @@ export default function McpPage() {
),
});
// ── Catalog install ──────────────────────────────────────────────────
const runInstall = useCallback(
async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
setInstallingName(entry.name);
try {
const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
if (res.background) {
showToast("Installing in background…", "success");
} else {
showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
}
setInstallEntry(null);
setInstallEnv({});
await Promise.all([loadServers(), loadCatalog()]);
} catch (e) {
showToast(`Failed to install: ${e}`, "error");
} finally {
setInstallingName(null);
}
},
[loadServers, loadCatalog, showToast],
);
const handleInstallClick = (entry: McpCatalogEntry) => {
if (entry.required_env.length > 0) {
const initial: Record<string, string> = {};
entry.required_env.forEach((item) => {
initial[item.name] = "";
});
setInstallEnv(initial);
setInstallEntry(entry);
} else {
void runInstall(entry, {});
}
};
const handleInstallSubmit = () => {
if (!installEntry) return;
const missing = installEntry.required_env.filter(
(item) => item.required && !(installEnv[item.name] ?? "").trim(),
);
if (missing.length > 0) {
showToast(`${missing[0].prompt} required`, "error");
return;
}
const envMap: Record<string, string> = {};
Object.entries(installEnv).forEach(([k, v]) => {
if (v.trim()) envMap[k] = v.trim();
});
void runInstall(installEntry, envMap);
};
// Put "Add Server" button in page header
useLayoutEffect(() => {
setEnd(
@@ -200,6 +304,11 @@ export default function McpPage() {
);
}
const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
diagnostics.forEach((d) => {
(diagnosticsByName[d.name] ??= []).push(d);
});
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
@@ -338,6 +447,91 @@ export default function McpPage() {
</div>
)}
{/* Catalog install modal (required env vars) */}
{installEntry && (
<div
ref={installModalRef}
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 && setInstallEntry(null)
}
role="dialog"
aria-modal="true"
aria-labelledby="install-mcp-title"
>
<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={() => setInstallEntry(null)}
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
id="install-mcp-title"
className="font-mondwest text-display text-base tracking-wider"
>
Install {installEntry.name}
</h2>
</header>
<div className="p-5 grid gap-4">
<p className="text-xs text-muted-foreground">
This MCP requires the following values to be configured.
</p>
{installEntry.required_env.map((item) => (
<div className="grid gap-2" key={item.name}>
<Label htmlFor={`install-env-${item.name}`}>
{item.prompt}
{item.required ? " *" : ""}
</Label>
<Input
id={`install-env-${item.name}`}
type="password"
placeholder={item.name}
value={installEnv[item.name] ?? ""}
onChange={(e) =>
setInstallEnv((prev) => ({
...prev,
[item.name]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleInstallSubmit}
disabled={installingName === installEntry.name}
prefix={
installingName === installEntry.name ? (
<Spinner />
) : undefined
}
>
{installingName === installEntry.name
? "Installing..."
: "Install"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* ── Your MCP servers ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@@ -345,10 +539,14 @@ export default function McpPage() {
className="flex items-center gap-2 text-muted-foreground"
>
<Server className="h-4 w-4" />
MCP Servers ({servers.length})
Your MCP servers ({servers.length})
</H2>
</div>
{restartNote && (
<p className="text-xs text-warning">{restartNote}</p>
)}
{servers.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
@@ -363,13 +561,20 @@ export default function McpPage() {
return (
<Card key={server.name}>
<CardContent className="flex items-start gap-4 py-4">
<CardContent
className={cn(
"flex items-start gap-4 py-4",
!server.enabled && "opacity-60",
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{server.name}
</span>
<Badge tone={TRANSPORT_TONE[server.transport] ?? "secondary"}>
<Badge
tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
>
{server.transport}
</Badge>
{!server.enabled && (
@@ -414,6 +619,25 @@ export default function McpPage() {
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="sm"
title={server.enabled ? "Disable" : "Enable"}
aria-label={server.enabled ? "Disable" : "Enable"}
onClick={() => handleToggleEnabled(server)}
disabled={togglingName === server.name}
prefix={
togglingName === server.name ? (
<Spinner />
) : (
<Power />
)
}
className={server.enabled ? "text-success" : undefined}
>
{server.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
size="icon"
@@ -441,6 +665,93 @@ export default function McpPage() {
);
})}
</div>
{/* ── Catalog ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Package className="h-4 w-4" />
Catalog ({catalog.length})
</H2>
</div>
<p className="text-xs text-muted-foreground">
Browse Nous-approved MCP servers and install them with one click.
</p>
{catalog.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No catalog entries available.
</CardContent>
</Card>
)}
{catalog.map((entry) => {
const entryDiags = diagnosticsByName[entry.name] ?? [];
const isInstalling = installingName === entry.name;
return (
<Card key={entry.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{entry.name}
</span>
<Badge
tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
>
{entry.transport}
</Badge>
<Badge tone="outline">
{entry.source === "official" ? "official" : entry.source}
</Badge>
{entry.installed && (
<Badge tone="success">Installed</Badge>
)}
{entry.installed && !entry.enabled && (
<Badge tone="outline">disabled</Badge>
)}
</div>
{entry.description && (
<p className="text-xs text-muted-foreground">
{entry.description}
</p>
)}
{entryDiags.map((d, i) => (
<p
key={`${entry.name}-diag-${i}`}
className="text-xs text-warning mt-1"
>
{d.message}
</p>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
{entry.installed ? (
<Badge tone="success">Installed</Badge>
) : (
<Button
className="uppercase"
size="sm"
onClick={() => handleInstallClick(entry)}
disabled={isInstalling}
prefix={isInstalling ? <Spinner /> : undefined}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
+321 -10
View File
@@ -23,12 +23,17 @@ import {
Hash,
X,
Play,
Download,
Pencil,
Check,
Archive,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
SessionStoreStats,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
@@ -44,6 +49,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/c
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Input } from "@nous-research/ui/ui/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useI18n } from "@/i18n";
@@ -262,6 +275,8 @@ function SessionRow({
isExpanded,
onToggle,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: {
session: SessionInfo;
@@ -270,11 +285,16 @@ function SessionRow({
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(session.title ?? "");
const [renameSaving, setRenameSaving] = useState(false);
const { t } = useI18n();
const navigate = useNavigate();
@@ -295,6 +315,21 @@ function SessionRow({
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
const submitRename = async () => {
const value = renameValue.trim();
if (!value || value === session.title) {
setRenaming(false);
return;
}
setRenameSaving(true);
try {
await onRename(session.id, value);
setRenaming(false);
} finally {
setRenameSaving(false);
}
};
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
@@ -317,6 +352,39 @@ function SessionRow({
</Button>
)}
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Rename session"
title="Rename session"
onClick={(e) => {
e.stopPropagation();
setRenameValue(
session.title && session.title !== "Untitled"
? session.title
: "",
);
setRenaming(true);
}}
>
<Pencil />
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Export session"
title="Export session JSON"
onClick={(e) => {
e.stopPropagation();
onExport(session.id);
}}
>
<Download />
</Button>
<Button
ghost
destructive
@@ -351,15 +419,61 @@ function SessionRow({
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{renaming ? (
<div
className="flex min-w-0 flex-1 items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitRename();
else if (e.key === "Escape") setRenaming(false);
}}
placeholder="Session title"
className="h-7 min-w-0 flex-1 py-0 text-sm"
disabled={renameSaving}
/>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label="Save title"
title="Save title"
disabled={renameSaving}
onClick={() => void submitRename()}
>
{renameSaving ? (
<Spinner className="text-sm" />
) : (
<Check />
)}
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Cancel rename"
title="Cancel rename"
disabled={renameSaving}
onClick={() => setRenaming(false)}
>
<X />
</Button>
</div>
) : (
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
)}
{session.is_active && (
<Badge tone="success" className="shrink-0 text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
@@ -492,9 +606,13 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
const [pruning, setPruning] = useState(false);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle } = usePageHeader();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
@@ -513,6 +631,23 @@ export default function SessionsPage() {
};
}, [loading, setAfterTitle, total]);
useEffect(() => {
setEnd(
<Button
outlined
size="sm"
className="gap-1.5"
onClick={() => setPruneOpen(true)}
>
<Archive className="h-3.5 w-3.5" />
Prune old sessions
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
api
@@ -525,6 +660,17 @@ export default function SessionsPage() {
.finally(() => setLoading(false));
}, []);
const loadStats = useCallback(() => {
api
.getSessionStats()
.then(setStats)
.catch(() => {});
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
@@ -583,6 +729,7 @@ export default function SessionsPage() {
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
@@ -591,12 +738,82 @@ export default function SessionsPage() {
[
expandedId,
showToast,
loadStats,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
const handleRename = useCallback(
async (id: string, title: string) => {
try {
await api.renameSession(id, title);
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
setOverviewSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
showToast("Session renamed", "success");
loadStats();
} catch {
showToast("Failed to rename session", "error");
}
},
[showToast, loadStats],
);
const handleExport = useCallback(
async (id: string) => {
try {
const res = await fetch(api.exportSessionUrl(id), {
credentials: "include",
headers: {
"X-Hermes-Session-Token":
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
.__HERMES_SESSION_TOKEN__ ?? "",
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${id}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
showToast("Failed to export session", "error");
}
},
[showToast],
);
const handlePrune = useCallback(async () => {
const days = parseInt(pruneDays, 10);
if (!Number.isFinite(days) || days < 0) {
showToast("Enter a valid number of days", "error");
return;
}
setPruning(true);
try {
const resp = await api.pruneSessions(days);
showToast(
`Pruned ${resp.removed} session${resp.removed === 1 ? "" : "s"}`,
"success",
);
setPruneOpen(false);
loadSessions(0);
setPage(0);
loadStats();
} catch {
showToast("Failed to prune sessions", "error");
} finally {
setPruning(false);
}
}, [pruneDays, showToast, loadSessions, loadStats]);
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
@@ -681,6 +898,98 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
if (!pruning) setPruneOpen(open);
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Prune old sessions</DialogTitle>
<DialogDescription>
Permanently remove archived sessions whose last activity is older
than the given number of days. Active sessions are never pruned.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<label
htmlFor="prune-days"
className="text-xs font-medium text-muted-foreground"
>
Older than (days)
</label>
<Input
id="prune-days"
type="number"
min={0}
value={pruneDays}
onChange={(e) => setPruneDays(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handlePrune();
}}
disabled={pruning}
/>
</div>
<DialogFooter>
<Button
outlined
onClick={() => setPruneOpen(false)}
disabled={pruning}
>
{t.common.cancel}
</Button>
<Button
destructive
onClick={() => void handlePrune()}
disabled={pruning}
className="gap-1.5"
>
{pruning && <Spinner className="text-sm" />}
Prune
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{stats && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border border-border bg-background-base/40 px-4 py-3">
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.total}
</span>
<span className="text-xs text-muted-foreground">Total</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none text-success">
{stats.active_store}
</span>
<span className="text-xs text-muted-foreground">Active in store</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.archived}
</span>
<span className="text-xs text-muted-foreground">Archived</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.messages}
</span>
<span className="text-xs text-muted-foreground">Messages</span>
</div>
{Object.keys(stats.by_source).length > 0 && (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{Object.entries(stats.by_source).map(([src, count]) => (
<Badge key={src} tone="outline" className="text-xs">
{src}: {count}
</Badge>
))}
</div>
)}
</div>
)}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
@@ -850,6 +1159,8 @@ export default function SessionsPage() {
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
+201 -3
View File
@@ -14,9 +14,11 @@ import {
Code,
Zap,
Filter,
Download,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import type { SkillInfo, ToolsetInfo, SkillHubResult } from "@/lib/api";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@@ -98,7 +100,7 @@ export default function SkillsPage() {
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [view, setView] = useState<"skills" | "toolsets" | "hub">("skills");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
@@ -284,6 +286,15 @@ export default function SkillsPage() {
setSearch("");
}}
/>
<PanelItem
icon={Search}
label="Browse hub"
active={view === "hub"}
onClick={() => {
setView("hub");
setSearch("");
}}
/>
</div>
{view === "skills" &&
@@ -408,7 +419,7 @@ export default function SkillsPage() {
)}
</CardContent>
</Card>
) : (
) : view === "toolsets" ? (
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
@@ -484,6 +495,8 @@ export default function SkillsPage() {
</div>
)}
</>
) : (
<HubBrowser showToast={showToast} />
)}
</div>
</div>
@@ -555,3 +568,188 @@ interface SkillRowProps {
skill: SkillInfo;
toggling: boolean;
}
/* ------------------------------------------------------------------ */
/* Hub browser — search the skill hub, install by identifier */
/* ------------------------------------------------------------------ */
function HubBrowser({
showToast,
}: {
showToast: (msg: string, kind: "success" | "error") => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SkillHubResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
// Live action log for the most recent install/update (tailed via action status).
const [action, setAction] = useState<string | null>(null);
const [actionLog, setActionLog] = useState<string[]>([]);
const [actionRunning, setActionRunning] = useState(false);
const runSearch = async () => {
const q = query.trim();
if (!q) return;
setSearching(true);
setSearched(true);
try {
const r = await api.searchSkillsHub(q);
setResults(r.results);
} catch (e) {
showToast(`Hub search failed: ${e}`, "error");
setResults([]);
} finally {
setSearching(false);
}
};
// Poll a spawned action's log until it exits.
useEffect(() => {
if (!action) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const st = await api.getActionStatus(action, 200);
if (cancelled) return;
setActionLog(st.lines);
setActionRunning(st.running);
if (st.running) timer = setTimeout(poll, 1200);
} catch {
if (!cancelled) setActionRunning(false);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [action]);
const install = async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
} catch (e) {
showToast(`Install failed: ${e}`, "error");
}
};
const updateAll = async () => {
try {
const res = await api.updateSkillsFromHub();
showToast("Updating installed skills…", "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
};
return (
<div className="flex flex-col gap-3">
<Card className="rounded-none">
<CardContent className="py-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 text-sm"
placeholder="Search the skill hub (GitHub, official, community)…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void runSearch();
}}
/>
</div>
<Button
size="sm"
onClick={() => void runSearch()}
disabled={searching || !query.trim()}
prefix={searching ? <Spinner /> : <Search className="h-3.5 w-3.5" />}
>
Search
</Button>
<Button
size="sm"
outlined
onClick={() => void updateAll()}
prefix={<RefreshCw className="h-3.5 w-3.5" />}
>
Update all
</Button>
</div>
<p className="text-xs text-muted-foreground">
Results come from the same sources as <span className="font-mono">hermes skills search</span>.
Installs run in the background; the log streams below.
</p>
</CardContent>
</Card>
{action && (
<Card className="rounded-none">
<CardContent className="py-3">
<div className="flex items-center gap-2 mb-2">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs">{action}</span>
{actionRunning ? (
<Badge tone="warning">running</Badge>
) : (
<Badge tone="success">done</Badge>
)}
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-2 text-xs font-mono text-muted-foreground">
{actionLog.length ? actionLog.join("\n") : "Starting…"}
</pre>
</CardContent>
</Card>
)}
{searching && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{!searching && searched && results.length === 0 && (
<Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No matching skills found in the hub.
</CardContent>
</Card>
)}
{results.map((r) => (
<Card key={r.identifier} className="rounded-none">
<CardContent className="py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono-ui text-sm">{r.name}</span>
<Badge tone="secondary" className="text-xs">{r.source}</Badge>
<Badge tone="outline" className="text-xs">{r.trust_level}</Badge>
</div>
<p className="text-xs text-text-secondary">{r.description}</p>
<p className="text-xs font-mono text-text-tertiary truncate mt-0.5">
{r.identifier}
</p>
</div>
<Button
size="sm"
outlined
className="shrink-0"
onClick={() => void install(r.identifier)}
prefix={<Download className="h-3.5 w-3.5" />}
>
Install
</Button>
</CardContent>
</Card>
))}
</div>
);
}
+445 -145
View File
@@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
Activity,
Brain,
Cpu,
Database,
Globe,
HardDrive,
KeyRound,
Play,
Plus,
Power,
RotateCw,
Server,
ShieldCheck,
Sparkles,
Stethoscope,
Terminal,
Trash2,
@@ -24,7 +30,9 @@ import { Label } from "@nous-research/ui/ui/components/label";
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 { useModalBehavior } from "@/hooks/useModalBehavior";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { cn, themedBody } from "@/lib/utils";
import { api } from "@/lib/api";
import type {
StatusResponse,
@@ -32,20 +40,32 @@ import type {
CredentialPoolProvider,
CheckpointsResponse,
HooksResponse,
HookEntry,
SystemStats,
CuratorStatus,
PortalStatus,
} from "@/lib/api";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
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`;
}
/**
* A running-action log viewer. The spawn-based admin actions (doctor,
* security audit, backup, import, skills update, checkpoints prune,
* gateway start/stop) stream their stdout to a per-action log file the
* server tails via /api/actions/<name>/status. When an action is launched
* we poll that endpoint until the process exits, showing live output.
* 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,
@@ -68,13 +88,9 @@ function ActionLogViewer({
setLines(st.lines);
setRunning(st.running);
setExitCode(st.exit_code);
if (st.running) {
timer.current = setTimeout(poll, 1200);
}
if (st.running) timer.current = setTimeout(poll, 1200);
} catch {
if (!cancelled) {
setRunning(false);
}
if (!cancelled) setRunning(false);
}
};
poll();
@@ -111,19 +127,30 @@ function ActionLogViewer({
);
}
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);
// Which spawn-action log is currently shown (null = none).
const [activeAction, setActiveAction] = useState<string | null>(null);
// Add-credential form.
@@ -132,23 +159,42 @@ export default function SystemPage() {
const [credLabel, setCredLabel] = useState("");
const [addingCred, setAddingCred] = useState(false);
// Import archive path.
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);
const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
api.getSystemStats(),
api.getMemory(),
api.getCredentialPool(),
api.getCheckpoints(),
api.getHooks(),
api.getCurator(),
api.getPortal(),
])
.then(([s, m, p, c, h]) => {
.then(([s, st, m, p, c, h, cur, prt]) => {
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);
})
.finally(() => setLoading(false));
}, []);
@@ -158,9 +204,7 @@ export default function SystemPage() {
}, [loadAll]);
// ── Gateway lifecycle ──────────────────────────────────────────────
const runGateway = async (
verb: "start" | "stop" | "restart",
) => {
const runGateway = async (verb: "start" | "stop" | "restart") => {
try {
if (verb === "start") {
await api.startGateway();
@@ -179,14 +223,23 @@ export default function SystemPage() {
}
};
// ── 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 ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(
`Memory provider: ${provider || "built-in only"}`,
"success",
);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
@@ -253,10 +306,7 @@ export default function SystemPage() {
});
// ── Operations ─────────────────────────────────────────────────────
const runOp = async (
fn: () => Promise<{ name: string }>,
label: string,
) => {
const runOp = async (fn: () => Promise<{ name: string }>, label: string) => {
try {
const res = await fn();
setActiveAction(res.name);
@@ -279,6 +329,53 @@ export default function SystemPage() {
}, [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">
@@ -288,6 +385,9 @@ export default function SystemPage() {
}
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">
@@ -317,6 +417,112 @@ export default function SystemPage() {
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 && (
@@ -326,6 +532,166 @@ export default function SystemPage() {
/>
)}
{/* ── 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>v{stats?.hermes_version}</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>
)}
</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 auth add nous --type oauth</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">
@@ -402,7 +768,6 @@ export default function SystemPage() {
<span className="font-mono">hermes memory setup</span>.
</p>
</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:{" "}
@@ -410,28 +775,13 @@ export default function SystemPage() {
{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")}
>
<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")}
>
<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")}
>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("all")}>
Reset all
</Button>
</div>
@@ -450,45 +800,22 @@ export default function SystemPage() {
<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"
/>
<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-…"
/>
<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"
/>
<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}
>
<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.
@@ -500,29 +827,12 @@ export default function SystemPage() {
{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"
>
<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>
<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}`,
)
}
>
{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>
@@ -540,52 +850,34 @@ export default function SystemPage() {
</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")}
>
<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")}
>
<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")}
>
Backup
<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")}
>
<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>
{/* Import from backup */}
<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"
/>
<Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" />
</div>
<Button
size="sm"
@@ -613,25 +905,23 @@ export default function SystemPage() {
{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")}
>
<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>
{/* ── Hooks ─────────────────────────────────────────────────── */}
{/* ── Shell hooks ───────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Terminal className="h-4 w-4" /> Shell hooks
</H2>
<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">
@@ -639,21 +929,31 @@ export default function SystemPage() {
</CardContent>
</Card>
)}
{hooks?.hooks.map((h, i) => (
{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="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>
)}
<span className="font-mono text-xs truncate flex-1">
{h.command}
</span>
<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>
))}
+37 -1
View File
@@ -128,6 +128,27 @@ export default function WebhooksPage() {
}
};
const [togglingName, setTogglingName] = useState<string | null>(null);
const handleToggleEnabled = useCallback(
async (subName: string, nextEnabled: boolean) => {
setTogglingName(subName);
try {
await api.setWebhookEnabled(subName, nextEnabled);
showToast(
nextEnabled ? `Enabled: "${subName}"` : `Disabled: "${subName}"`,
"success",
);
loadWebhooks();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
},
[loadWebhooks, showToast],
);
const webhookDelete = useConfirmDelete({
onDelete: useCallback(
async (name: string) => {
@@ -378,6 +399,11 @@ export default function WebhooksPage() {
Subscriptions ({subscriptions.length})
</H2>
<p className="text-xs text-muted-foreground -mt-1">
Disabled webhooks reject incoming events; the gateway hot-reloads
changes (no restart needed).
</p>
{subscriptions.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
@@ -389,7 +415,7 @@ export default function WebhooksPage() {
{subscriptions.map((sub: WebhookRoute) => (
<Card key={sub.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className={cn("flex-1 min-w-0", !sub.enabled && "opacity-60")}>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{sub.name}
@@ -398,6 +424,7 @@ export default function WebhooksPage() {
{sub.deliver_only && (
<Badge tone="secondary">deliver only</Badge>
)}
{!sub.enabled && <Badge tone="warning">disabled</Badge>}
</div>
{sub.description && (
@@ -427,6 +454,15 @@ export default function WebhooksPage() {
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="sm"
className="uppercase"
disabled={togglingName === sub.name}
onClick={() => handleToggleEnabled(sub.name, !sub.enabled)}
>
{sub.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
destructive