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.
|
|
@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||
{"error": f"Unknown route: {route_name}"}, status=404
|
||||
)
|
||||
|
||||
# Disabled routes are kept in the subscriptions file (so the dashboard
|
||||
# can re-enable them) but reject incoming events. Default-enabled:
|
||||
# only an explicit ``enabled: false`` turns a route off, matching the
|
||||
# mcp_servers ``enabled`` semantics.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return web.json_response(
|
||||
{"error": f"Route disabled: {route_name}"}, status=403
|
||||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
|
|
|
|||
|
|
@ -753,6 +753,225 @@ async def get_status():
|
|||
}
|
||||
|
||||
|
||||
@app.get("/api/system/stats")
|
||||
async def get_system_stats():
|
||||
"""Host + process system stats for the System page.
|
||||
|
||||
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
|
||||
psutil when available, with graceful degradation when it isn't. Read-only
|
||||
and non-sensitive (no env values, no paths beyond the hermes home root).
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"os": _platform.system(),
|
||||
"os_release": _platform.release(),
|
||||
"os_version": _platform.version(),
|
||||
"platform": _platform.platform(),
|
||||
"arch": _platform.machine(),
|
||||
"hostname": _platform.node(),
|
||||
"python_version": _platform.python_version(),
|
||||
"python_impl": _platform.python_implementation(),
|
||||
"hermes_version": __version__,
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
# psutil enriches the picture when present; everything below is optional.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
info["memory"] = {
|
||||
"total": vm.total,
|
||||
"available": vm.available,
|
||||
"used": vm.used,
|
||||
"percent": vm.percent,
|
||||
}
|
||||
try:
|
||||
du = psutil.disk_usage(str(get_hermes_home()))
|
||||
info["disk"] = {
|
||||
"total": du.total,
|
||||
"used": du.used,
|
||||
"free": du.free,
|
||||
"percent": du.percent,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
||||
la = getattr(psutil, "getloadavg", None)
|
||||
if la:
|
||||
info["load_avg"] = list(la())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
boot = psutil.boot_time()
|
||||
info["uptime_seconds"] = int(time.time() - boot)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
info["process"] = {
|
||||
"pid": proc.pid,
|
||||
"rss": proc.memory_info().rss,
|
||||
"create_time": int(proc.create_time()),
|
||||
"num_threads": proc.num_threads(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
info["psutil"] = True
|
||||
except Exception:
|
||||
info["psutil"] = False
|
||||
# stdlib-only fallbacks for load average + uptime where the kernel
|
||||
# exposes them.
|
||||
try:
|
||||
info["load_avg"] = list(os.getloadavg())
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curator endpoints — background skill-maintenance status + controls.
|
||||
#
|
||||
# The curator periodically reviews skills (archive stale, prune, pin). The
|
||||
# dashboard surfaces its state and the pause/resume/run-now controls that
|
||||
# `hermes curator` exposes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/curator")
|
||||
async def get_curator_status():
|
||||
try:
|
||||
from agent import curator
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
|
||||
try:
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
return {
|
||||
"enabled": _safe_call(curator, "is_enabled", True),
|
||||
"paused": _safe_call(curator, "is_paused", False),
|
||||
"interval_hours": _safe_call(curator, "get_interval_hours", None),
|
||||
"last_run_at": state.get("last_run_at"),
|
||||
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
|
||||
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
|
||||
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
|
||||
}
|
||||
|
||||
|
||||
class CuratorPause(BaseModel):
|
||||
paused: bool
|
||||
|
||||
|
||||
@app.put("/api/curator/paused")
|
||||
async def set_curator_paused(body: CuratorPause):
|
||||
from agent import curator
|
||||
|
||||
curator.set_paused(bool(body.paused))
|
||||
return {"ok": True, "paused": bool(body.paused)}
|
||||
|
||||
|
||||
@app.post("/api/curator/run")
|
||||
async def run_curator():
|
||||
"""Trigger a curator review now (backgrounded; tail via action status)."""
|
||||
try:
|
||||
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
|
||||
|
||||
|
||||
def _safe_call(mod, fn_name: str, default):
|
||||
try:
|
||||
fn = getattr(mod, fn_name, None)
|
||||
return fn() if callable(fn) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/portal")
|
||||
async def get_portal_status():
|
||||
cfg = load_config() or {}
|
||||
auth: Dict[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
|
||||
auth = get_nous_auth_status() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
features = []
|
||||
try:
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
feats = get_nous_subscription_features(cfg)
|
||||
if feats is not None:
|
||||
for feat in feats.items():
|
||||
if getattr(feat, "managed_by_nous", False):
|
||||
state = "via Nous Portal"
|
||||
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
|
||||
state = feat.current_provider
|
||||
elif getattr(feat, "active", False):
|
||||
state = "active"
|
||||
else:
|
||||
state = "not configured"
|
||||
features.append({"label": getattr(feat, "label", ""), "state": state})
|
||||
except Exception:
|
||||
_log.exception("portal features failed")
|
||||
|
||||
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
|
||||
return {
|
||||
"logged_in": bool(auth.get("logged_in")),
|
||||
"portal_url": auth.get("portal_base_url"),
|
||||
"inference_url": auth.get("inference_base_url"),
|
||||
"provider": str((model_cfg or {}).get("provider") or ""),
|
||||
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
|
||||
# All produce text output, so they spawn background actions tailed via
|
||||
# /api/actions/<name>/status.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/ops/prompt-size")
|
||||
async def run_prompt_size():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
|
||||
|
||||
|
||||
@app.post("/api/ops/dump")
|
||||
async def run_dump():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["dump"], "dump")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "dump"}
|
||||
|
||||
|
||||
@app.post("/api/ops/config-migrate")
|
||||
async def run_config_migrate():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway + update actions (invoked from the Status page).
|
||||
#
|
||||
|
|
@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
|||
"skills-install": "action-skills-install.log",
|
||||
"skills-uninstall": "action-skills-uninstall.log",
|
||||
"skills-update": "action-skills-update.log",
|
||||
"curator-run": "action-curator-run.log",
|
||||
"prompt-size": "action-prompt-size.log",
|
||||
"dump": "action-dump.log",
|
||||
"config-migrate": "action-config-migrate.log",
|
||||
}
|
||||
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
|
|
@ -3690,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
|
|||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
archived = db.session_count(archived_only=True)
|
||||
messages = db.message_count()
|
||||
by_source: Dict[str, int] = {}
|
||||
try:
|
||||
for s in db.list_sessions_rich(limit=10000, include_archived=True):
|
||||
src = str(s.get("source") or "cli")
|
||||
by_source[src] = by_source.get(src, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"total": total,
|
||||
"active_store": active_store,
|
||||
"archived": archived,
|
||||
"messages": messages,
|
||||
"by_source": by_source,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
|
|
@ -3782,6 +4037,49 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
|||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
data = db.export_session(sid)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return data
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class SessionPrune(BaseModel):
|
||||
older_than_days: int = 90
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/prune")
|
||||
async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
|
||||
if body.older_than_days < 1:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
removed = db.prune_sessions(
|
||||
older_than_days=body.older_than_days,
|
||||
source=(body.source or None),
|
||||
sessions_dir=sessions_dir if sessions_dir.exists() else None,
|
||||
)
|
||||
return {"ok": True, "removed": removed}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log viewer endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -4172,6 +4470,129 @@ async def test_mcp_server(name: str):
|
|||
}
|
||||
|
||||
|
||||
class MCPEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/mcp/servers/{name}/enabled")
|
||||
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
|
||||
"""Enable or disable an MCP server (takes effect on next session/gateway).
|
||||
|
||||
Toggles the ``enabled`` key on the server's config.yaml entry — the same
|
||||
flag the agent reads at startup. Disabled servers stay in config so they
|
||||
can be re-enabled without re-entering their settings.
|
||||
"""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
@app.get("/api/mcp/catalog")
|
||||
async def list_mcp_catalog():
|
||||
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
|
||||
|
||||
Each entry reports whether it's already installed and enabled so the UI
|
||||
can show install / enabled state inline. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` read.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import mcp_catalog
|
||||
except Exception as exc:
|
||||
_log.exception("mcp_catalog import failed")
|
||||
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in mcp_catalog.list_catalog():
|
||||
auth = entry.auth
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"source": entry.source,
|
||||
"transport": entry.transport.type,
|
||||
"auth_type": getattr(auth, "type", "none"),
|
||||
# Env vars the user must supply (names + prompts only, never values).
|
||||
"required_env": [
|
||||
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
||||
for e in getattr(auth, "env", []) or []
|
||||
],
|
||||
"needs_install": entry.install is not None,
|
||||
"installed": mcp_catalog.is_installed(entry.name),
|
||||
"enabled": mcp_catalog.is_enabled(entry.name),
|
||||
})
|
||||
except Exception:
|
||||
_log.exception("list_mcp_catalog failed")
|
||||
|
||||
diagnostics = []
|
||||
try:
|
||||
diagnostics = [
|
||||
{"name": n, "kind": k, "message": m}
|
||||
for (n, k, m) in mcp_catalog.catalog_diagnostics()
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"entries": entries, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
class MCPCatalogInstall(BaseModel):
|
||||
name: str
|
||||
# env: KEY=VALUE map for catalog entries that declare required env vars.
|
||||
env: Dict[str, str] = {}
|
||||
enable: bool = True
|
||||
|
||||
|
||||
@app.post("/api/mcp/catalog/install")
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
|
||||
"""Install a catalog MCP into config.yaml.
|
||||
|
||||
For HTTP/stdio entries with required env vars, those are written to .env
|
||||
via the standard env path so the agent can read them at session start.
|
||||
Entries that need a git bootstrap (``needs_install``) are installed via
|
||||
the CLI action path because the clone can take time.
|
||||
"""
|
||||
from hermes_cli import mcp_catalog
|
||||
|
||||
name = (body.name or "").strip()
|
||||
entry = mcp_catalog.get_entry(name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
|
||||
|
||||
# Persist any supplied env vars first (catalog entries declare which names
|
||||
# they need; we only write the ones the user provided).
|
||||
if body.env:
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
|
||||
# Git-bootstrap entries can take a while to clone — run via the background
|
||||
# action path so the request returns immediately and the UI can tail logs.
|
||||
if entry.install is not None:
|
||||
try:
|
||||
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
|
||||
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
|
||||
|
||||
# No git step — install synchronously via the catalog API.
|
||||
try:
|
||||
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
|
||||
except Exception as exc:
|
||||
_log.exception("install_mcp_catalog_entry failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"ok": True, "name": name, "background": False}
|
||||
|
||||
|
||||
# Register the mcp-install action log so /api/actions/mcp-install/status works.
|
||||
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pairing endpoints — approve / revoke / list messaging pairing codes.
|
||||
#
|
||||
|
|
@ -4283,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
|
|||
"url": f"{base_url}/webhooks/{name}",
|
||||
# Secret is masked on read; full value only returned on create.
|
||||
"secret_set": bool(route.get("secret")),
|
||||
# Default-enabled; only an explicit enabled:false turns a route off.
|
||||
"enabled": route.get("enabled", True) is not False,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -4367,6 +4790,30 @@ async def delete_webhook(name: str):
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
class WebhookEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/webhooks/{name}/enabled")
|
||||
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
||||
"""Enable or disable a webhook route.
|
||||
|
||||
Disabled routes stay in the subscriptions file (so they can be
|
||||
re-enabled) but the gateway rejects incoming events with 403. The
|
||||
gateway hot-reloads the subscriptions file, so this takes effect on the
|
||||
next event without a restart.
|
||||
"""
|
||||
import hermes_cli.webhook as wh
|
||||
|
||||
key = (name or "").strip().lower()
|
||||
subs = wh._load_subscriptions()
|
||||
if key not in subs:
|
||||
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
|
||||
subs[key]["enabled"] = bool(body.enabled)
|
||||
wh._save_subscriptions(subs)
|
||||
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway lifecycle endpoints — start / stop.
|
||||
#
|
||||
|
|
@ -4688,38 +5135,160 @@ async def run_import(body: ImportRequest):
|
|||
|
||||
@app.get("/api/ops/hooks")
|
||||
async def list_hooks():
|
||||
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
|
||||
"""List configured shell hooks from config.yaml with consent + health.
|
||||
|
||||
Reports each hook's allowlist (consent) status and whether the script is
|
||||
currently executable, plus the set of valid hook events so the create
|
||||
form can offer them.
|
||||
"""
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
from agent import shell_hooks
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
valid_events = sorted(VALID_HOOKS)
|
||||
except Exception:
|
||||
valid_events = []
|
||||
|
||||
specs = []
|
||||
try:
|
||||
specs = shell_hooks.iter_configured_hooks(_load_config())
|
||||
except Exception:
|
||||
_log.exception("iter_configured_hooks failed")
|
||||
|
||||
out = []
|
||||
for spec in specs:
|
||||
entry = None
|
||||
try:
|
||||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
executable = False
|
||||
try:
|
||||
executable = shell_hooks.script_is_executable(spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
out.append({
|
||||
"event": spec.event,
|
||||
"matcher": spec.matcher,
|
||||
"command": spec.command,
|
||||
"timeout": spec.timeout,
|
||||
"allowed": entry is not None,
|
||||
"approved_at": (entry or {}).get("approved_at"),
|
||||
"executable": executable,
|
||||
})
|
||||
|
||||
return {"hooks": out, "valid_events": valid_events}
|
||||
|
||||
|
||||
class HookCreate(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
matcher: Optional[str] = None
|
||||
timeout: Optional[int] = None
|
||||
# approve: write the consent allowlist entry too (the operator using the
|
||||
# authenticated dashboard is giving consent). Without it the hook is
|
||||
# configured but won't fire until approved.
|
||||
approve: bool = True
|
||||
|
||||
|
||||
@app.post("/api/ops/hooks")
|
||||
async def create_hook(body: HookCreate):
|
||||
"""Add a shell hook to config.yaml (and optionally approve it).
|
||||
|
||||
Shell hooks run arbitrary commands, so this is a privileged action: it
|
||||
writes to the ``hooks:`` config block and, when ``approve`` is set, records
|
||||
consent in the allowlist so the hook actually fires. Takes effect on the
|
||||
next session / gateway restart.
|
||||
"""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
if event not in VALID_HOOKS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = load_config()
|
||||
hooks_cfg = cfg.get("hooks")
|
||||
out = []
|
||||
if isinstance(hooks_cfg, dict):
|
||||
for event, entries in hooks_cfg.items():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
out.append({
|
||||
"event": event,
|
||||
"matcher": entry.get("matcher"),
|
||||
"command": entry.get("command"),
|
||||
"timeout": entry.get("timeout"),
|
||||
})
|
||||
# Consent allowlist status (which commands have been approved for run).
|
||||
allowlist: List[str] = []
|
||||
if not isinstance(hooks_cfg, dict):
|
||||
hooks_cfg = {}
|
||||
cfg["hooks"] = hooks_cfg
|
||||
entries = hooks_cfg.get(event)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
hooks_cfg[event] = entries
|
||||
|
||||
new_entry: Dict[str, Any] = {"command": command}
|
||||
if body.matcher:
|
||||
new_entry["matcher"] = body.matcher
|
||||
if body.timeout is not None:
|
||||
new_entry["timeout"] = int(body.timeout)
|
||||
entries.append(new_entry)
|
||||
save_config(cfg)
|
||||
|
||||
approved = False
|
||||
if body.approve:
|
||||
try:
|
||||
shell_hooks._record_approval(event, command)
|
||||
approved = True
|
||||
except Exception:
|
||||
_log.exception("hook consent record failed")
|
||||
|
||||
return {"ok": True, "event": event, "command": command, "approved": approved}
|
||||
|
||||
|
||||
class HookDelete(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
|
||||
|
||||
@app.delete("/api/ops/hooks")
|
||||
async def delete_hook(body: HookDelete):
|
||||
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
cfg = load_config()
|
||||
hooks_cfg = cfg.get("hooks")
|
||||
removed = False
|
||||
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
|
||||
before = len(hooks_cfg[event])
|
||||
hooks_cfg[event] = [
|
||||
e for e in hooks_cfg[event]
|
||||
if not (isinstance(e, dict) and e.get("command") == command)
|
||||
]
|
||||
removed = len(hooks_cfg[event]) < before
|
||||
if not hooks_cfg[event]:
|
||||
del hooks_cfg[event]
|
||||
if not hooks_cfg:
|
||||
cfg.pop("hooks", None)
|
||||
save_config(cfg)
|
||||
|
||||
# Revoke consent regardless so a re-add re-prompts.
|
||||
try:
|
||||
allow_path = get_hermes_home() / "shell-hooks-allowlist.json"
|
||||
if allow_path.exists():
|
||||
data = json.loads(allow_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
allowlist = list(data.keys())
|
||||
elif isinstance(data, list):
|
||||
allowlist = [str(x) for x in data]
|
||||
shell_hooks.revoke(command)
|
||||
except Exception:
|
||||
_log.exception("Failed to read shell-hooks allowlist")
|
||||
for h in out:
|
||||
h["allowed"] = h.get("command") in allowlist
|
||||
return {"hooks": out, "allowlist": allowlist}
|
||||
pass
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="No matching hook found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/ops/checkpoints")
|
||||
|
|
@ -4818,6 +5387,46 @@ async def update_skills_hub():
|
|||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
||||
|
||||
|
||||
@app.get("/api/skills/hub/search")
|
||||
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
|
||||
"""Search the skill hub across all configured sources.
|
||||
|
||||
Network-bound (parallel source search); runs in a thread so the FastAPI
|
||||
loop isn't blocked. Returns structured results the UI installs by
|
||||
identifier via POST /api/skills/hub/install.
|
||||
"""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
return {"results": []}
|
||||
|
||||
def _run():
|
||||
from tools.skills_hub import create_source_router, unified_search
|
||||
|
||||
sources = create_source_router()
|
||||
metas = unified_search(
|
||||
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"source": m.source,
|
||||
"identifier": m.identifier,
|
||||
"trust_level": m.trust_level,
|
||||
"repo": m.repo,
|
||||
"tags": list(m.tags or []),
|
||||
}
|
||||
for m in metas
|
||||
]
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub search failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -74,6 +74,35 @@ class TestMcpEndpoints:
|
|||
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_disable_toggle(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
|
||||
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
srv = [
|
||||
s for s in self.client.get("/api/mcp/servers").json()["servers"]
|
||||
if s["name"] == "tog"
|
||||
][0]
|
||||
assert srv["enabled"] is False
|
||||
# Toggling a missing server is a 404.
|
||||
assert self.client.put(
|
||||
"/api/mcp/servers/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
def test_catalog_lists_entries(self):
|
||||
r = self.client.get("/api/mcp/catalog")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "entries" in body and "diagnostics" in body
|
||||
# The shipped optional-mcps/ catalog has at least one entry; each must
|
||||
# carry the install/enabled status fields the UI relies on.
|
||||
for e in body["entries"]:
|
||||
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
|
||||
|
||||
def test_catalog_install_unknown_404(self):
|
||||
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestCredentialPoolEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -190,6 +219,40 @@ class TestOpsEndpoints:
|
|||
save_config(cfg)
|
||||
data = self.client.get("/api/ops/hooks").json()
|
||||
assert data["hooks"][0]["command"] == "/bin/echo hi"
|
||||
assert "valid_events" in data and len(data["valid_events"]) >= 1
|
||||
|
||||
def test_hook_create_and_delete(self):
|
||||
# Create with consent approval.
|
||||
r = self.client.post(
|
||||
"/api/ops/hooks",
|
||||
json={
|
||||
"event": "pre_tool_call",
|
||||
"command": "/bin/echo created",
|
||||
"matcher": "terminal",
|
||||
"timeout": 7,
|
||||
"approve": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["approved"] is True
|
||||
|
||||
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
created = [h for h in hooks if h["command"] == "/bin/echo created"]
|
||||
assert created and created[0]["allowed"] is True
|
||||
|
||||
# Unknown event rejected.
|
||||
assert self.client.post(
|
||||
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
|
||||
).status_code == 400
|
||||
|
||||
# Delete it.
|
||||
r = self.client.request(
|
||||
"DELETE",
|
||||
"/api/ops/hooks",
|
||||
json={"event": "pre_tool_call", "command": "/bin/echo created"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
|
||||
|
||||
def test_checkpoints_list_empty(self):
|
||||
data = self.client.get("/api/ops/checkpoints").json()
|
||||
|
|
@ -200,6 +263,131 @@ class TestOpsEndpoints:
|
|||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestSystemStatsEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_stats_shape(self):
|
||||
r = self.client.get("/api/system/stats")
|
||||
assert r.status_code == 200
|
||||
s = r.json()
|
||||
# Identity fields always present (stdlib-sourced).
|
||||
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
|
||||
assert key in s and s[key]
|
||||
# psutil flag tells the UI whether the richer metrics are populated.
|
||||
assert "psutil" in s
|
||||
|
||||
|
||||
class TestCuratorEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_and_pause_toggle(self):
|
||||
r = self.client.get("/api/curator")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"enabled", "paused", "interval_hours"} <= set(body)
|
||||
# Pause then resume; the read reflects the write.
|
||||
r = self.client.put("/api/curator/paused", json={"paused": True})
|
||||
assert r.status_code == 200 and r.json()["paused"] is True
|
||||
assert self.client.get("/api/curator").json()["paused"] is True
|
||||
r = self.client.put("/api/curator/paused", json={"paused": False})
|
||||
assert r.status_code == 200 and r.json()["paused"] is False
|
||||
|
||||
|
||||
class TestPortalEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_shape(self):
|
||||
r = self.client.get("/api/portal")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
|
||||
assert isinstance(body["features"], list)
|
||||
|
||||
|
||||
class TestSessionManagementEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db.create_session(session_id="sess-x", source="cli")
|
||||
db.close()
|
||||
|
||||
def test_stats_not_shadowed_by_session_id_route(self):
|
||||
# /api/sessions/stats must resolve to the stats handler, not be captured
|
||||
# as {session_id}="stats" by the parameterized route registered after it.
|
||||
r = self.client.get("/api/sessions/stats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
|
||||
assert body["total"] >= 1
|
||||
|
||||
def test_rename(self):
|
||||
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
|
||||
assert r.status_code == 200 and r.json()["title"] == "Renamed"
|
||||
|
||||
def test_export(self):
|
||||
r = self.client.get("/api/sessions/sess-x/export")
|
||||
assert r.status_code == 200 and "messages" in r.json()
|
||||
assert self.client.get("/api/sessions/nope/export").status_code == 404
|
||||
|
||||
def test_prune_validation(self):
|
||||
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
|
||||
assert r.status_code == 200 and "removed" in r.json()
|
||||
assert self.client.post(
|
||||
"/api/sessions/prune", json={"older_than_days": 0}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestSkillsHubSearchEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
# Empty query short-circuits (no network) and returns no results.
|
||||
r = self.client.get("/api/skills/hub/search?q=")
|
||||
assert r.status_code == 200 and r.json() == {"results": []}
|
||||
|
||||
|
||||
|
||||
|
||||
class TestWebhookToggleEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
# Enable the webhook platform so a subscription can be created.
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("platforms", {})["webhook"] = {
|
||||
"enabled": True,
|
||||
"extra": {"host": "0.0.0.0", "port": 8644},
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
def test_create_toggle_disable(self):
|
||||
r = self.client.post(
|
||||
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["enabled"] is True
|
||||
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
subs = self.client.get("/api/webhooks").json()["subscriptions"]
|
||||
assert subs[0]["enabled"] is False
|
||||
assert self.client.put(
|
||||
"/api/webhooks/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
|
|
@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate:
|
|||
"/api/memory",
|
||||
"/api/ops/hooks",
|
||||
"/api/ops/checkpoints",
|
||||
"/api/curator",
|
||||
"/api/portal",
|
||||
"/api/system/stats",
|
||||
],
|
||||
)
|
||||
def test_gated(self, path):
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -142,10 +142,16 @@ Advanced/rarely-used keys are hidden by default behind a toggle.
|
|||
Browse and inspect all agent sessions. Each row shows the session title, source platform icon (CLI, Telegram, Discord, Slack, cron), model name, message count, tool call count, and how long ago it was active. Live sessions are marked with a pulsing badge.
|
||||
|
||||
- **Search** — full-text search across all message content using FTS5. Results show highlighted snippets and auto-scroll to the first matching message when expanded.
|
||||
- **Stats** — a summary bar shows total sessions, how many are active in the store, archived count, total messages, and a per-source breakdown.
|
||||
- **Expand** — click a session to load its full message history. Messages are color-coded by role (user, assistant, system, tool) and rendered as Markdown with syntax highlighting.
|
||||
- **Tool calls** — assistant messages with tool calls show collapsible blocks with the function name and JSON arguments.
|
||||
- **Rename** — set or clear a session's title inline (pencil icon).
|
||||
- **Export** — download a session (metadata + full message history) as JSON (download icon).
|
||||
- **Prune** — the header "Prune old sessions" button deletes ended sessions older than N days.
|
||||
- **Delete** — remove a session and its message history with the trash icon.
|
||||
|
||||

|
||||
|
||||
### Logs
|
||||
|
||||
View agent, gateway, and error log files with filtering and live tailing.
|
||||
|
|
@ -173,28 +179,42 @@ Create and manage scheduled cron jobs that run agent prompts on a recurring sche
|
|||
- **Create** — fill in a name (optional), prompt, cron expression (e.g. `0 9 * * *`), and delivery target (local, Telegram, Discord, Slack, or email)
|
||||
- **Job list** — each job shows its name, prompt preview, schedule expression, state badge (enabled/paused/error), delivery target, last run time, and next run time
|
||||
- **Pause / Resume** — toggle a job between active and paused states
|
||||
- **Edit** — open a pre-filled modal to change a job's prompt, schedule, name, or delivery target
|
||||
- **Trigger now** — immediately execute a job outside its normal schedule
|
||||
- **Delete** — permanently remove a cron job
|
||||
|
||||
### Skills
|
||||
|
||||
Browse, search, and toggle skills and toolsets. Skills are loaded from `~/.hermes/skills/` and grouped by category.
|
||||
Browse, search, and toggle installed skills and toolsets, and install new ones from the hub. Skills are loaded from `~/.hermes/skills/` and grouped by category.
|
||||
|
||||
- **Search** — filter skills and toolsets by name, description, or category
|
||||
- **Search** — filter installed skills and toolsets by name, description, or category
|
||||
- **Category filter** — click category pills to narrow the list (e.g. MLOps, MCP, Red Teaming, AI)
|
||||
- **Toggle** — enable or disable individual skills with a switch. Changes take effect on the next session.
|
||||
- **Toolsets** — a separate section shows built-in toolsets (file operations, web browsing, etc.) with their active/inactive status, setup requirements, and list of included tools
|
||||
- **Toolsets** — a separate view shows built-in toolsets (file operations, web browsing, etc.) with their active/inactive status, setup requirements, and list of included tools
|
||||
- **Browse hub** — a third view searches the skill hub across all sources (the same as `hermes skills search`), installs any result by identifier with a live install log, and offers an "Update all" button to refresh installed skills.
|
||||
|
||||

|
||||
|
||||
### MCP
|
||||
|
||||
Manage [MCP](/integrations/mcp) servers without the CLI. The same `mcp_servers`
|
||||
block in `config.yaml` that `hermes mcp` reads from.
|
||||
|
||||
**Your MCP servers:**
|
||||
|
||||
- **Add** — register an HTTP/SSE server (URL) or a stdio server (command + args), with optional `KEY=VALUE` environment variables for stdio servers
|
||||
- **Enable / disable** — toggle a server on or off without deleting it. A disabled server stays in config so you can re-enable it later. Takes effect on the next gateway restart.
|
||||
- **Test** — connect to a server, list its tools, and disconnect — verifies the connection before the agent depends on it
|
||||
- **Remove** — delete a server from the config
|
||||
- Secret-shaped env values are redacted in the list view
|
||||
|
||||
**Catalog:** browse the Nous-approved MCP servers (the bundled `optional-mcps/`
|
||||
catalog) and install any of them with one click. Entries that need API keys
|
||||
prompt for them inline; the values go to `.env`. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` use.
|
||||
|
||||

|
||||
|
||||
### Webhooks
|
||||
|
||||
Manage dynamic [webhook subscriptions](/user-guide/messaging/webhooks). The
|
||||
|
|
@ -202,28 +222,47 @@ webhook platform must be enabled in messaging settings first; the page shows a
|
|||
hint when it isn't.
|
||||
|
||||
- **Create** — name, description, event filter, delivery target, optional direct-delivery mode, and an agent prompt. On creation the page surfaces the route URL and the one-time HMAC secret to copy.
|
||||
- **Enable / disable** — toggle a subscription on or off. Disabled routes stay in the subscriptions file but the gateway rejects their incoming events (403). The gateway hot-reloads the file, so the change takes effect on the next event — no restart needed.
|
||||
- **List** — each subscription shows its URL, events, and delivery target
|
||||
- **Delete** — remove a subscription (hot-reloaded by the gateway, no restart needed)
|
||||
- **Delete** — remove a subscription
|
||||
|
||||

|
||||
|
||||
### Pairing
|
||||
|
||||
Approve and revoke messaging users without the CLI — how a remote admin
|
||||
onboards Telegram/Discord/etc. users to a paired gateway.
|
||||
onboards Telegram/Discord/etc. users to a paired gateway. Full parity with
|
||||
`hermes pairing`.
|
||||
|
||||
- **Pending requests** — each shows platform, code, user, and age, with an Approve button
|
||||
- **Approved users** — each shows platform and user, with a Revoke button
|
||||
- **Clear pending** — drop all outstanding pairing codes
|
||||
|
||||

|
||||
|
||||
### System
|
||||
|
||||
A consolidated administration panel for installation-wide operations:
|
||||
|
||||
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.)
|
||||
- **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`.
|
||||
- **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`.
|
||||
- **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state)
|
||||
- **Memory** — pick the external memory provider (or built-in only), and reset the built-in `MEMORY.md` / `USER.md` stores
|
||||
- **Credential pool** — add and remove the rotating API keys the agent round-robins through (per provider). Keys are redacted in the list; the raw value only ever reaches the agent.
|
||||
- **Operations** — run `doctor`, a security audit, a backup, restore from a backup archive, or update skills. Each spawns a background action whose live log streams into the page.
|
||||
- **Operations** — run `doctor`, a security audit, create a backup, restore from a backup archive, update skills, show the system-prompt size breakdown, generate a support dump, or migrate config for retired settings. Each spawns a background action whose live log streams into the page.
|
||||
- **Checkpoints** — see the `/rollback` shadow store size and prune it
|
||||
- **Shell hooks** — read-only list of configured hooks with their consent-allowlist status
|
||||
- **Shell hooks** — list configured hooks with their consent + executable status, **create** a hook (event, command, matcher, timeout, with an opt-in consent grant), and remove one. Hooks run arbitrary commands, so the create form carries a security warning and the hook only fires after consent is granted.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Creating a shell hook (note the consent checkbox and the run-arbitrary-commands warning):
|
||||
|
||||

|
||||
|
||||
:::warning Security
|
||||
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
|
||||
|
|
@ -350,7 +389,10 @@ same auth gate as the rest of `/api/`.
|
|||
| `GET /api/mcp/servers` | List configured MCP servers (env values redacted) |
|
||||
| `POST /api/mcp/servers` | Add a server. Body: `{name, url?, command?, args?, env?, auth?}` |
|
||||
| `POST /api/mcp/servers/{name}/test` | Connect, list tools, disconnect |
|
||||
| `PUT /api/mcp/servers/{name}/enabled` | Enable / disable a server |
|
||||
| `DELETE /api/mcp/servers/{name}` | Remove a server |
|
||||
| `GET /api/mcp/catalog` | Browse the Nous-approved MCP catalog |
|
||||
| `POST /api/mcp/catalog/install` | Install a catalog entry (with required env) |
|
||||
| `GET /api/pairing` | List pending + approved messaging users |
|
||||
| `POST /api/pairing/approve` | Approve a code. Body: `{platform, code}` |
|
||||
| `POST /api/pairing/revoke` | Revoke a user. Body: `{platform, user_id}` |
|
||||
|
|
@ -368,7 +410,19 @@ same auth gate as the rest of `/api/`.
|
|||
| `POST /api/ops/doctor` · `/security-audit` · `/backup` · `/import` | Diagnostics & maintenance (backgrounded; tail via `/api/actions/{name}/status`) |
|
||||
| `GET /api/ops/hooks` | Configured shell hooks + allowlist status |
|
||||
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
|
||||
| `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) |
|
||||
| `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime |
|
||||
| `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run |
|
||||
| `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) |
|
||||
| `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |
|
||||
| `PUT /api/webhooks/{name}/enabled` | Enable / disable a webhook route |
|
||||
| `POST /api/skills/hub/install` · `/uninstall` · `/update` | Skills hub actions (backgrounded) |
|
||||
| `GET /api/skills/hub/search` | Search the skill hub across all sources |
|
||||
| `GET /api/sessions/stats` | Session-store statistics |
|
||||
| `PATCH /api/sessions/{id}` | Rename / archive a session |
|
||||
| `GET /api/sessions/{id}/export` | Export a session (metadata + messages) as JSON |
|
||||
| `POST /api/sessions/prune` | Delete ended sessions older than N days |
|
||||
| `PUT /api/cron/jobs/{id}` | Edit a cron job's prompt / schedule / name / deliver |
|
||||
|
||||
## OAuth Authentication (gated mode)
|
||||
|
||||
|
|
|
|||
BIN
website/static/img/dashboard/admin-hook-create.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
website/static/img/dashboard/admin-mcp.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
website/static/img/dashboard/admin-pairing.png
Normal file
|
After Width: | Height: | Size: 471 KiB |
BIN
website/static/img/dashboard/admin-sessions.png
Normal file
|
After Width: | Height: | Size: 480 KiB |
BIN
website/static/img/dashboard/admin-skills-hub.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
website/static/img/dashboard/admin-system-curator.png
Normal file
|
After Width: | Height: | Size: 483 KiB |
BIN
website/static/img/dashboard/admin-system-ops.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
website/static/img/dashboard/admin-system-top.png
Normal file
|
After Width: | Height: | Size: 507 KiB |
BIN
website/static/img/dashboard/admin-webhooks.png
Normal file
|
After Width: | Height: | Size: 488 KiB |