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:
+202
-1
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user