feat(dashboard): full administration panel — MCP, pairing, webhooks, credentials, memory, gateway, ops (#36704)
* feat(dashboard): backend API for MCP, pairing, webhooks, credential pool, memory, gateway lifecycle Adds REST endpoints so a remote admin can manage these without CLI access: - MCP servers: list/add/remove/test (config.yaml parity with hermes mcp) - Pairing: list/approve/revoke/clear-pending messaging codes - Webhooks: list/subscribe/remove (hot-reloaded JSON store) - Credential pool: list/add/remove rotation keys (via CredentialPool API) - Memory provider: status/select/disable/reset - Gateway lifecycle: start/stop (restart+update already existed) Secrets redacted on read; usable values only reach the agent at session start. All endpoints sit behind the existing dashboard auth gate. * feat(dashboard): backend API for ops + skills hub - Ops actions (spawned, log-tailed via /api/actions): doctor, security audit, backup, import, checkpoints prune - Ops reads (structured JSON): hooks list + allowlist status, checkpoints list with per-session size - Skills hub actions (spawned): install / uninstall / update - Registers new action log files for all spawn-based endpoints All gated by the existing dashboard auth middleware. * feat(dashboard): admin pages for MCP, pairing, webhooks, and system ops Adds four new dashboard pages + nav entries so a remote admin can manage Hermes without CLI access: - MCP: list/add/remove/test MCP servers - Webhooks: list/create/delete subscriptions (one-time secret reveal) - Pairing: approve/revoke/clear messaging pairing codes - System: gateway start/stop/restart, memory provider + reset, credential pool add/remove, ops (doctor/audit/backup/import/skills update) with a live action-log viewer, checkpoints prune, shell-hooks status api.ts: client methods + types for all new endpoints. App.tsx: routes + sidebar nav (plain labels, no i18n key required). Verified: tsc -b clean, production build succeeds, new pages lint clean, zero new eslint errors in App.tsx. * test(dashboard): cover admin API endpoints 20 tests across MCP, credential pool, memory, pairing, webhooks, ops, plus an auth-gate parametrize that asserts every admin endpoint requires the session token. Asserts request contract + CLI-config parity, not catalog values (per the no-change-detector-tests rule). * docs(dashboard): document MCP, Webhooks, Pairing, and System admin pages Adds Pages sections for the four new admin tabs and an Admin-endpoints table to the REST API reference. Updates the page description to reflect the dashboard's expanded role as a full administration panel.
This commit is contained in:
@ -768,7 +768,17 @@ _ACTION_LOG_DIR: Path = get_hermes_home() / "logs"
|
||||
# Short ``name`` (from the URL) → absolute log file path.
|
||||
_ACTION_LOG_FILES: Dict[str, str] = {
|
||||
"gateway-restart": "gateway-restart.log",
|
||||
"gateway-start": "gateway-start.log",
|
||||
"gateway-stop": "gateway-stop.log",
|
||||
"hermes-update": "hermes-update.log",
|
||||
"doctor": "action-doctor.log",
|
||||
"security-audit": "action-security-audit.log",
|
||||
"backup": "action-backup.log",
|
||||
"import": "action-import.log",
|
||||
"checkpoints-prune": "action-checkpoints-prune.log",
|
||||
"skills-install": "action-skills-install.log",
|
||||
"skills-uninstall": "action-skills-uninstall.log",
|
||||
"skills-update": "action-skills-update.log",
|
||||
}
|
||||
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
@ -3980,6 +3990,782 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server endpoints — list / add / remove / test.
|
||||
#
|
||||
# Wraps the same config data layer the CLI uses (hermes_cli.mcp_config), so
|
||||
# servers managed here show up under `hermes mcp list` and vice versa. Secrets
|
||||
# in stdio `env` blocks are redacted on read; the agent picks them up from
|
||||
# config.yaml at session start exactly as with CLI-added servers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MCPServerCreate(BaseModel):
|
||||
name: str
|
||||
url: Optional[str] = None
|
||||
command: Optional[str] = None
|
||||
args: List[str] = []
|
||||
# env: KEY=VALUE map for stdio servers (API keys, etc.)
|
||||
env: Dict[str, str] = {}
|
||||
# auth: "oauth" | "header" | None
|
||||
auth: Optional[str] = None
|
||||
|
||||
|
||||
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Mask secret-shaped MCP env values for read responses."""
|
||||
out: Dict[str, str] = {}
|
||||
for k, v in (env or {}).items():
|
||||
try:
|
||||
out[str(k)] = redact_key(str(v)) if v else ""
|
||||
except Exception:
|
||||
out[str(k)] = "***"
|
||||
return out
|
||||
|
||||
|
||||
def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown")
|
||||
return {
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"url": cfg.get("url"),
|
||||
"command": cfg.get("command"),
|
||||
"args": list(cfg.get("args") or []),
|
||||
"env": _redact_mcp_env(cfg.get("env") or {}),
|
||||
"auth": cfg.get("auth"),
|
||||
"enabled": cfg.get("enabled", True) is not False,
|
||||
# Tool selection: list of enabled tool names, or None = all.
|
||||
"tools": cfg.get("tools"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/mcp/servers")
|
||||
async def list_mcp_servers():
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
servers = _get_mcp_servers()
|
||||
return {
|
||||
"servers": [
|
||||
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/mcp/servers")
|
||||
async def add_mcp_server(body: MCPServerCreate):
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server
|
||||
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Server name is required")
|
||||
if name in _get_mcp_servers():
|
||||
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
|
||||
if not body.url and not body.command:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either a URL (HTTP/SSE server) or a command (stdio server)",
|
||||
)
|
||||
|
||||
server_config: Dict[str, Any] = {}
|
||||
if body.url:
|
||||
server_config["url"] = body.url.strip()
|
||||
if body.command:
|
||||
server_config["command"] = body.command.strip()
|
||||
if body.args:
|
||||
server_config["args"] = list(body.args)
|
||||
if body.env:
|
||||
server_config["env"] = dict(body.env)
|
||||
if body.auth:
|
||||
server_config["auth"] = body.auth
|
||||
|
||||
try:
|
||||
_save_mcp_server(name, server_config)
|
||||
except Exception as exc:
|
||||
_log.exception("POST /api/mcp/servers failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return _mcp_server_summary(name, server_config)
|
||||
|
||||
|
||||
@app.delete("/api/mcp/servers/{name}")
|
||||
async def remove_mcp_server(name: str):
|
||||
from hermes_cli.mcp_config import _remove_mcp_server
|
||||
|
||||
if not _remove_mcp_server(name):
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/mcp/servers/{name}/test")
|
||||
async def test_mcp_server(name: str):
|
||||
"""Connect to the server, list its tools, disconnect. Returns tool list."""
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
|
||||
|
||||
servers = _get_mcp_servers()
|
||||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
|
||||
try:
|
||||
# Probe blocks on a dedicated MCP event loop — run in a thread so the
|
||||
# FastAPI event loop is never blocked.
|
||||
tools = await asyncio.to_thread(_probe_single_server, name, servers[name])
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"tools": [],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"tools": [{"name": t, "description": d} for t, d in tools],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pairing endpoints — approve / revoke / list messaging pairing codes.
|
||||
#
|
||||
# These are how a remote admin onboards messaging users (Telegram, Discord, …)
|
||||
# without shell access. Wraps gateway.pairing.PairingStore directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PairingApprove(BaseModel):
|
||||
platform: str
|
||||
code: str
|
||||
|
||||
|
||||
class PairingRevoke(BaseModel):
|
||||
platform: str
|
||||
user_id: str
|
||||
|
||||
|
||||
def _pairing_store():
|
||||
from gateway.pairing import PairingStore
|
||||
|
||||
return PairingStore()
|
||||
|
||||
|
||||
@app.get("/api/pairing")
|
||||
async def list_pairing():
|
||||
store = _pairing_store()
|
||||
return {
|
||||
"pending": store.list_pending(),
|
||||
"approved": store.list_approved(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/pairing/approve")
|
||||
async def approve_pairing(body: PairingApprove):
|
||||
store = _pairing_store()
|
||||
platform = (body.platform or "").lower().strip()
|
||||
code = (body.code or "").upper().strip()
|
||||
if not platform or not code:
|
||||
raise HTTPException(status_code=400, detail="platform and code are required")
|
||||
|
||||
result = store.approve_code(platform, code)
|
||||
if result:
|
||||
return {"ok": True, "user": result}
|
||||
if store._is_locked_out(platform):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Platform '{platform}' is locked out after too many failed approvals.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Code '{code}' not found or expired for platform '{platform}'.",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/pairing/revoke")
|
||||
async def revoke_pairing(body: PairingRevoke):
|
||||
store = _pairing_store()
|
||||
platform = (body.platform or "").lower().strip()
|
||||
if not platform or not body.user_id:
|
||||
raise HTTPException(status_code=400, detail="platform and user_id are required")
|
||||
if store.revoke(platform, body.user_id):
|
||||
return {"ok": True}
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"User {body.user_id} not found in approved list for {platform}.",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/pairing/clear-pending")
|
||||
async def clear_pending_pairing():
|
||||
store = _pairing_store()
|
||||
count = store.clear_pending()
|
||||
return {"ok": True, "cleared": count}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook subscription endpoints — list / subscribe / remove.
|
||||
#
|
||||
# Wraps the same JSON store the CLI uses (hermes_cli.webhook); the webhook
|
||||
# adapter hot-reloads it without a gateway restart. Per-route HMAC secrets
|
||||
# are redacted on read and surfaced once on create.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WebhookCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
events: List[str] = []
|
||||
prompt: Optional[str] = None
|
||||
skills: List[str] = []
|
||||
deliver: str = "log"
|
||||
deliver_only: bool = False
|
||||
deliver_chat_id: Optional[str] = None
|
||||
# secret: omit to auto-generate
|
||||
secret: Optional[str] = None
|
||||
|
||||
|
||||
def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"description": route.get("description", ""),
|
||||
"events": list(route.get("events") or []),
|
||||
"deliver": route.get("deliver", "log"),
|
||||
"deliver_only": bool(route.get("deliver_only")),
|
||||
"prompt": route.get("prompt", ""),
|
||||
"skills": list(route.get("skills") or []),
|
||||
"created_at": route.get("created_at"),
|
||||
"url": f"{base_url}/webhooks/{name}",
|
||||
# Secret is masked on read; full value only returned on create.
|
||||
"secret_set": bool(route.get("secret")),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/webhooks")
|
||||
async def list_webhooks():
|
||||
import hermes_cli.webhook as wh
|
||||
|
||||
base_url = wh._get_webhook_base_url()
|
||||
subs = wh._load_subscriptions()
|
||||
return {
|
||||
"enabled": wh._is_webhook_enabled(),
|
||||
"base_url": base_url,
|
||||
"subscriptions": [
|
||||
_webhook_route_summary(name, route, base_url)
|
||||
for name, route in subs.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/webhooks")
|
||||
async def create_webhook(body: WebhookCreate):
|
||||
import re as _re
|
||||
import secrets as _secrets
|
||||
import time as _time
|
||||
import hermes_cli.webhook as wh
|
||||
|
||||
if not wh._is_webhook_enabled():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Webhook platform is not enabled. Enable it in messaging settings first.",
|
||||
)
|
||||
|
||||
name = (body.name or "").strip().lower().replace(" ", "-")
|
||||
if not _re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid name. Use lowercase alphanumeric with hyphens/underscores.",
|
||||
)
|
||||
|
||||
if body.deliver_only and body.deliver == "log":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Direct delivery requires a real target (telegram, discord, …), not 'log'.",
|
||||
)
|
||||
|
||||
secret = body.secret or _secrets.token_urlsafe(32)
|
||||
route: Dict[str, Any] = {
|
||||
"description": body.description or f"Dashboard-created subscription: {name}",
|
||||
"events": [e.strip() for e in body.events if e.strip()],
|
||||
"secret": secret,
|
||||
"prompt": body.prompt or "",
|
||||
"skills": [s.strip() for s in body.skills if s.strip()],
|
||||
"deliver": body.deliver or "log",
|
||||
"created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
||||
}
|
||||
if body.deliver_only:
|
||||
route["deliver_only"] = True
|
||||
if body.deliver_chat_id:
|
||||
route["deliver_extra"] = {"chat_id": body.deliver_chat_id}
|
||||
|
||||
subs = wh._load_subscriptions()
|
||||
subs[name] = route
|
||||
wh._save_subscriptions(subs)
|
||||
|
||||
base_url = wh._get_webhook_base_url()
|
||||
summary = _webhook_route_summary(name, route, base_url)
|
||||
# Surface the secret exactly once, on create.
|
||||
summary["secret"] = secret
|
||||
return summary
|
||||
|
||||
|
||||
@app.delete("/api/webhooks/{name}")
|
||||
async def delete_webhook(name: str):
|
||||
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}'")
|
||||
del subs[key]
|
||||
wh._save_subscriptions(subs)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway lifecycle endpoints — start / stop.
|
||||
#
|
||||
# restart + update already exist above; these complete the lifecycle so a
|
||||
# remote admin can bring the gateway up or down without shell access. Both
|
||||
# spawn the real `hermes gateway <verb>` so behaviour matches the CLI exactly.
|
||||
# Status is already surfaced by /api/status (gateway_running/state/platforms).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/gateway/start")
|
||||
async def start_gateway():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway start")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "gateway-start"}
|
||||
|
||||
|
||||
@app.post("/api/gateway/stop")
|
||||
async def stop_gateway():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway stop")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "gateway-stop"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential pool endpoints — list / add / remove rotation keys.
|
||||
#
|
||||
# The credential pool (auth.json -> credential_pool.<provider>[]) holds the
|
||||
# rotating API keys the agent round-robins through. Secrets are redacted on
|
||||
# read; only the agent ever sees the raw values at session start.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CredentialPoolAdd(BaseModel):
|
||||
provider: str
|
||||
# api_key for API-key providers; OAuth pooling stays CLI-only (it needs
|
||||
# an interactive browser flow that doesn't belong in a single POST).
|
||||
api_key: str
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
def _pool_entry_summary(entry: Any, index: int) -> Dict[str, Any]:
|
||||
"""Redacted, display-safe view of one PooledCredential.
|
||||
|
||||
``index`` is 1-based to match CredentialPool.remove_index().
|
||||
"""
|
||||
token = getattr(entry, "access_token", "") or ""
|
||||
return {
|
||||
"index": index,
|
||||
"id": getattr(entry, "id", None),
|
||||
"label": getattr(entry, "label", None),
|
||||
"auth_type": getattr(entry, "auth_type", None),
|
||||
"source": getattr(entry, "source", None),
|
||||
"priority": getattr(entry, "priority", 0),
|
||||
"last_status": getattr(entry, "last_status", None),
|
||||
"request_count": getattr(entry, "request_count", 0),
|
||||
"token_preview": redact_key(token) if token else "",
|
||||
"has_refresh": bool(getattr(entry, "refresh_token", None)),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/credentials/pool")
|
||||
async def list_credential_pool():
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
|
||||
providers = []
|
||||
# read_credential_pool(None) lists every provider that has pooled entries;
|
||||
# load_pool() then gives us the rich PooledCredential objects per provider.
|
||||
raw_pool = read_credential_pool()
|
||||
for provider_id in sorted(raw_pool.keys()):
|
||||
try:
|
||||
pool = load_pool(provider_id)
|
||||
except Exception:
|
||||
_log.exception("load_pool(%s) failed", provider_id)
|
||||
continue
|
||||
entries = pool.entries()
|
||||
if not entries:
|
||||
continue
|
||||
providers.append({
|
||||
"provider": provider_id,
|
||||
"entries": [
|
||||
_pool_entry_summary(e, i) for i, e in enumerate(entries, start=1)
|
||||
],
|
||||
})
|
||||
return {"providers": providers}
|
||||
|
||||
|
||||
@app.post("/api/credentials/pool")
|
||||
async def add_credential_pool_entry(body: CredentialPoolAdd):
|
||||
import uuid as _uuid
|
||||
from agent.credential_pool import (
|
||||
load_pool,
|
||||
PooledCredential,
|
||||
AUTH_TYPE_API_KEY,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
|
||||
provider = (body.provider or "").strip().lower()
|
||||
api_key = (body.api_key or "").strip()
|
||||
if not provider or not api_key:
|
||||
raise HTTPException(status_code=400, detail="provider and api_key are required")
|
||||
|
||||
try:
|
||||
pool = load_pool(provider)
|
||||
label = (body.label or "").strip() or f"key #{len(pool.entries()) + 1}"
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=_uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_API_KEY,
|
||||
priority=0,
|
||||
source=SOURCE_MANUAL,
|
||||
access_token=api_key,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
except Exception as exc:
|
||||
_log.exception("POST /api/credentials/pool failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, "provider": provider, "count": len(pool.entries())}
|
||||
|
||||
|
||||
@app.delete("/api/credentials/pool/{provider}/{index}")
|
||||
async def remove_credential_pool_entry(provider: str, index: int):
|
||||
"""Remove a pool entry. ``index`` is 1-based (matches the list response)."""
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
provider = (provider or "").strip().lower()
|
||||
try:
|
||||
pool = load_pool(provider)
|
||||
removed = pool.remove_index(index)
|
||||
except Exception as exc:
|
||||
_log.exception("DELETE /api/credentials/pool failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if removed is None:
|
||||
raise HTTPException(status_code=404, detail="No pool entry at that index")
|
||||
return {"ok": True, "provider": provider, "count": len(pool.entries())}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory provider endpoints — status / list providers / select / disable / reset.
|
||||
#
|
||||
# Selecting a provider only writes config.memory.provider (full interactive
|
||||
# provider setup, with its API-key prompts, stays on the CLI via
|
||||
# `hermes memory setup`). The dashboard covers the common admin actions:
|
||||
# see which provider is active, switch the built-in store on/off, and wipe
|
||||
# built-in memory files.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MemoryProviderSelect(BaseModel):
|
||||
# "" or "built-in" disables the external provider (built-in only).
|
||||
provider: str
|
||||
|
||||
|
||||
class MemoryReset(BaseModel):
|
||||
# "all" | "memory" | "user"
|
||||
target: str = "all"
|
||||
|
||||
|
||||
@app.get("/api/memory")
|
||||
async def get_memory_status():
|
||||
from plugins.memory import discover_memory_providers
|
||||
|
||||
cfg = load_config()
|
||||
active = ""
|
||||
mem = cfg.get("memory")
|
||||
if isinstance(mem, dict):
|
||||
active = str(mem.get("provider") or "")
|
||||
|
||||
providers = []
|
||||
try:
|
||||
for name, description, configured in discover_memory_providers():
|
||||
providers.append({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"configured": bool(configured),
|
||||
})
|
||||
except Exception:
|
||||
_log.exception("discover_memory_providers failed")
|
||||
|
||||
# Built-in memory file sizes (so the UI can show what a reset would erase).
|
||||
mem_dir = get_hermes_home() / "memories"
|
||||
files = {}
|
||||
for fname, key in (("MEMORY.md", "memory"), ("USER.md", "user")):
|
||||
path = mem_dir / fname
|
||||
files[key] = path.stat().st_size if path.exists() else 0
|
||||
|
||||
return {
|
||||
"active": active,
|
||||
"providers": providers,
|
||||
"builtin_files": files,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/memory/provider")
|
||||
async def set_memory_provider(body: MemoryProviderSelect):
|
||||
provider = (body.provider or "").strip()
|
||||
if provider.lower() in {"built-in", "builtin", "none"}:
|
||||
provider = ""
|
||||
|
||||
if provider:
|
||||
from plugins.memory import discover_memory_providers
|
||||
|
||||
valid = {name for name, _d, _c in discover_memory_providers()}
|
||||
if provider not in valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown memory provider '{provider}'. Run `hermes memory setup` to configure a new one.",
|
||||
)
|
||||
|
||||
cfg = load_config()
|
||||
if not isinstance(cfg.get("memory"), dict):
|
||||
cfg["memory"] = {}
|
||||
cfg["memory"]["provider"] = provider
|
||||
save_config(cfg)
|
||||
return {"ok": True, "active": provider}
|
||||
|
||||
|
||||
@app.post("/api/memory/reset")
|
||||
async def reset_memory(body: MemoryReset):
|
||||
target = (body.target or "all").strip().lower()
|
||||
if target not in {"all", "memory", "user"}:
|
||||
raise HTTPException(status_code=400, detail="target must be all, memory, or user")
|
||||
|
||||
mem_dir = get_hermes_home() / "memories"
|
||||
deleted = []
|
||||
targets = []
|
||||
if target in {"all", "memory"}:
|
||||
targets.append("MEMORY.md")
|
||||
if target in {"all", "user"}:
|
||||
targets.append("USER.md")
|
||||
for fname in targets:
|
||||
path = mem_dir / fname
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
deleted.append(fname)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not delete {fname}: {exc}")
|
||||
return {"ok": True, "deleted": deleted}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operations endpoints — doctor / security audit / backup / import /
|
||||
# checkpoints / hooks.
|
||||
#
|
||||
# Diagnostic and maintenance commands. The long-running / text-output ones
|
||||
# (doctor, security audit, backup, import, skills install) are spawned as
|
||||
# background actions whose logs the dashboard tails via
|
||||
# /api/actions/{name}/status — same pattern as gateway restart and update.
|
||||
# The cheap, structured reads (hooks list, checkpoints list) return JSON
|
||||
# directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/ops/doctor")
|
||||
async def run_doctor():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["doctor"], "doctor")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn doctor")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run doctor: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "doctor"}
|
||||
|
||||
|
||||
@app.post("/api/ops/security-audit")
|
||||
async def run_security_audit():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["security", "audit"], "security-audit")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn security audit")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run security audit: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "security-audit"}
|
||||
|
||||
|
||||
class BackupRequest(BaseModel):
|
||||
# Optional output path; defaults to a timestamped zip in the home dir.
|
||||
output: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/ops/backup")
|
||||
async def run_backup(body: BackupRequest):
|
||||
args = ["backup"]
|
||||
if body.output:
|
||||
args.append(body.output.strip())
|
||||
try:
|
||||
proc = _spawn_hermes_action(args, "backup")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn backup")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run backup: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "backup"}
|
||||
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
archive: str
|
||||
|
||||
|
||||
@app.post("/api/ops/import")
|
||||
async def run_import(body: ImportRequest):
|
||||
archive = (body.archive or "").strip()
|
||||
if not archive:
|
||||
raise HTTPException(status_code=400, detail="archive path is required")
|
||||
if not os.path.isfile(archive):
|
||||
raise HTTPException(status_code=404, detail=f"Archive not found: {archive}")
|
||||
try:
|
||||
proc = _spawn_hermes_action(["import", archive], "import")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn import")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "import"}
|
||||
|
||||
|
||||
@app.get("/api/ops/hooks")
|
||||
async def list_hooks():
|
||||
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
|
||||
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] = []
|
||||
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]
|
||||
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}
|
||||
|
||||
|
||||
@app.get("/api/ops/checkpoints")
|
||||
async def list_checkpoints():
|
||||
"""List the /rollback shadow store checkpoints (read-only)."""
|
||||
# Checkpoints live under <hermes_home>/checkpoints/. Surface a count +
|
||||
# total size so the dashboard can show what a prune would reclaim; the
|
||||
# actual prune is a spawned action so confirmation/pruning logic stays
|
||||
# in one place (the CLI).
|
||||
cp_dir = get_hermes_home() / "checkpoints"
|
||||
sessions = []
|
||||
total_bytes = 0
|
||||
if cp_dir.is_dir():
|
||||
for child in sorted(cp_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
size = 0
|
||||
count = 0
|
||||
for f in child.rglob("*"):
|
||||
if f.is_file():
|
||||
try:
|
||||
size += f.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
pass
|
||||
total_bytes += size
|
||||
sessions.append({
|
||||
"session": child.name,
|
||||
"files": count,
|
||||
"bytes": size,
|
||||
})
|
||||
return {"sessions": sessions, "total_bytes": total_bytes}
|
||||
|
||||
|
||||
@app.post("/api/ops/checkpoints/prune")
|
||||
async def prune_checkpoints():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["checkpoints", "prune"], "checkpoints-prune")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn checkpoints prune")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to prune checkpoints: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "checkpoints-prune"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skills hub endpoints — search / install / uninstall / update.
|
||||
#
|
||||
# Search and install touch the network (GitHub, hub sources) and run the same
|
||||
# complex source-router pipeline the CLI uses, so they're spawned as background
|
||||
# actions whose logs the dashboard tails. The already-installed skill list +
|
||||
# enable/disable toggle live in the existing /api/skills endpoints.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkillInstallRequest(BaseModel):
|
||||
identifier: str
|
||||
|
||||
|
||||
@app.post("/api/skills/hub/install")
|
||||
async def install_skill_hub(body: SkillInstallRequest):
|
||||
identifier = (body.identifier or "").strip()
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=400, detail="identifier is required")
|
||||
try:
|
||||
proc = _spawn_hermes_action(["skills", "install", identifier], "skills-install")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills install")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-install"}
|
||||
|
||||
|
||||
class SkillUninstallRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@app.post("/api/skills/hub/uninstall")
|
||||
async def uninstall_skill_hub(body: SkillUninstallRequest):
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
try:
|
||||
proc = _spawn_hermes_action(["skills", "uninstall", name, "--yes"], "skills-uninstall")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills uninstall")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-uninstall"}
|
||||
|
||||
|
||||
@app.post("/api/skills/hub/update")
|
||||
async def update_skills_hub():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["skills", "update"], "skills-update")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills update")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
228
tests/hermes_cli/test_dashboard_admin_endpoints.py
Normal file
228
tests/hermes_cli/test_dashboard_admin_endpoints.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""Tests for the dashboard admin API endpoints (MCP, pairing, webhooks,
|
||||
credential pool, memory, gateway lifecycle, ops, skills hub).
|
||||
|
||||
These endpoints turn the web dashboard into an administration panel for
|
||||
operators without CLI access to the host. The tests assert the request
|
||||
contract and the CLI-config parity (servers/keys written via the API are
|
||||
visible to the CLI data layer), not specific catalog values.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _client():
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
client = TestClient(app)
|
||||
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Keep the state DB under the isolated HERMES_HOME for any handler that
|
||||
# touches it.
|
||||
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
return client, _SESSION_HEADER_NAME
|
||||
|
||||
|
||||
class TestMcpEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, self.header = _client()
|
||||
|
||||
def test_list_add_remove_roundtrip(self):
|
||||
assert self.client.get("/api/mcp/servers").json()["servers"] == []
|
||||
|
||||
r = self.client.post(
|
||||
"/api/mcp/servers", json={"name": "srv1", "url": "https://x/mcp"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["transport"] == "http"
|
||||
|
||||
servers = self.client.get("/api/mcp/servers").json()["servers"]
|
||||
assert [s["name"] for s in servers] == ["srv1"]
|
||||
|
||||
# CLI parity: the server is in config.yaml under mcp_servers.
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
assert "srv1" in _get_mcp_servers()
|
||||
|
||||
assert self.client.delete("/api/mcp/servers/srv1").status_code == 200
|
||||
assert self.client.get("/api/mcp/servers").json()["servers"] == []
|
||||
|
||||
def test_stdio_env_is_redacted_on_read(self):
|
||||
self.client.post(
|
||||
"/api/mcp/servers",
|
||||
json={
|
||||
"name": "srv2",
|
||||
"command": "npx",
|
||||
"args": ["-y", "pkg"],
|
||||
"env": {"API_KEY": "sk-secret-1234567890"},
|
||||
},
|
||||
)
|
||||
srv = self.client.get("/api/mcp/servers").json()["servers"][0]
|
||||
assert srv["env"]["API_KEY"] != "sk-secret-1234567890"
|
||||
|
||||
def test_duplicate_rejected(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
assert r.status_code == 409
|
||||
|
||||
def test_missing_transport_rejected(self):
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestCredentialPoolEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_add_list_remove_and_cli_parity(self):
|
||||
assert self.client.get("/api/credentials/pool").json()["providers"] == []
|
||||
|
||||
r = self.client.post(
|
||||
"/api/credentials/pool",
|
||||
json={"provider": "openrouter", "api_key": "sk-or-abcdef1234", "label": "p"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["count"] == 1
|
||||
|
||||
providers = self.client.get("/api/credentials/pool").json()["providers"]
|
||||
entry = providers[0]["entries"][0]
|
||||
# API redacts the key but exposes a preview + 1-based index.
|
||||
assert entry["index"] == 1
|
||||
assert entry["token_preview"] != "sk-or-abcdef1234"
|
||||
|
||||
# CLI parity: the raw, usable key is retrievable via the pool API.
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
raw = load_pool("openrouter").entries()
|
||||
assert raw[0].access_token == "sk-or-abcdef1234"
|
||||
|
||||
assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
|
||||
assert self.client.delete("/api/credentials/pool/openrouter/99").status_code == 404
|
||||
|
||||
def test_empty_body_rejected(self):
|
||||
r = self.client.post(
|
||||
"/api/credentials/pool", json={"provider": "", "api_key": ""}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestMemoryEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(get_hermes_home() / "memories").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def test_status_and_select(self):
|
||||
data = self.client.get("/api/memory").json()
|
||||
assert "active" in data and "providers" in data and "builtin_files" in data
|
||||
|
||||
r = self.client.put("/api/memory/provider", json={"provider": "built-in"})
|
||||
assert r.status_code == 200 and r.json()["active"] == ""
|
||||
|
||||
r = self.client.put(
|
||||
"/api/memory/provider", json={"provider": "no-such-provider-xyz"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_reset_targets(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
mem = get_hermes_home() / "memories"
|
||||
(mem / "MEMORY.md").write_text("notes")
|
||||
(mem / "USER.md").write_text("user")
|
||||
|
||||
r = self.client.post("/api/memory/reset", json={"target": "user"})
|
||||
assert r.status_code == 200 and "USER.md" in r.json()["deleted"]
|
||||
assert (mem / "MEMORY.md").exists()
|
||||
|
||||
assert self.client.post(
|
||||
"/api/memory/reset", json={"target": "bogus"}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestPairingEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_list_and_bad_approve(self):
|
||||
data = self.client.get("/api/pairing").json()
|
||||
assert data == {"pending": [], "approved": []}
|
||||
r = self.client.post(
|
||||
"/api/pairing/approve", json={"platform": "telegram", "code": "NOPE99"}
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestWebhookEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_list_disabled_and_create_blocked(self):
|
||||
data = self.client.get("/api/webhooks").json()
|
||||
assert data["enabled"] is False
|
||||
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestOpsEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_hooks_list_reads_config(self):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["hooks"] = {
|
||||
"pre_tool_call": [
|
||||
{"matcher": "terminal", "command": "/bin/echo hi", "timeout": 5}
|
||||
]
|
||||
}
|
||||
save_config(cfg)
|
||||
data = self.client.get("/api/ops/hooks").json()
|
||||
assert data["hooks"][0]["command"] == "/bin/echo hi"
|
||||
|
||||
def test_checkpoints_list_empty(self):
|
||||
data = self.client.get("/api/ops/checkpoints").json()
|
||||
assert data == {"sessions": [], "total_bytes": 0}
|
||||
|
||||
def test_import_missing_archive_404(self):
|
||||
r = self.client.post("/api/ops/import", json={"archive": "/no/such.zip"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
# No session header → must be rejected.
|
||||
self.client = TestClient(app)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/api/mcp/servers",
|
||||
"/api/pairing",
|
||||
"/api/webhooks",
|
||||
"/api/credentials/pool",
|
||||
"/api/memory",
|
||||
"/api/ops/hooks",
|
||||
"/api/ops/checkpoints",
|
||||
],
|
||||
)
|
||||
def test_gated(self, path):
|
||||
resp = self.client.get(path)
|
||||
assert resp.status_code in (401, 403)
|
||||
@ -35,14 +35,17 @@ import {
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plug,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
Users,
|
||||
Webhook,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
@ -72,6 +75,10 @@ import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import PluginsPage from "@/pages/PluginsPage";
|
||||
import McpPage from "@/pages/McpPage";
|
||||
import PairingPage from "@/pages/PairingPage";
|
||||
import WebhooksPage from "@/pages/WebhooksPage";
|
||||
import SystemPage from "@/pages/SystemPage";
|
||||
import ChatPage from "@/pages/ChatPage";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
@ -121,6 +128,10 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/cron": CronPage,
|
||||
"/skills": SkillsPage,
|
||||
"/plugins": PluginsPage,
|
||||
"/mcp": McpPage,
|
||||
"/pairing": PairingPage,
|
||||
"/webhooks": WebhooksPage,
|
||||
"/system": SystemPage,
|
||||
"/profiles": ProfilesPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
@ -158,9 +169,13 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
|
||||
{ path: "/mcp", label: "MCP", icon: Plug },
|
||||
{ path: "/webhooks", label: "Webhooks", icon: Webhook },
|
||||
{ path: "/pairing", label: "Pairing", icon: ShieldCheck },
|
||||
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
|
||||
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||
{ path: "/system", label: "System", icon: Wrench },
|
||||
{
|
||||
path: "/docs",
|
||||
labelKey: "documentation",
|
||||
|
||||
@ -504,6 +504,137 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// ── Admin: MCP servers ──────────────────────────────────────────────
|
||||
getMcpServers: () => fetchJSON<{ servers: McpServer[] }>("/api/mcp/servers"),
|
||||
addMcpServer: (body: McpServerCreate) =>
|
||||
fetchJSON<McpServer>("/api/mcp/servers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
removeMcpServer: (name: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
testMcpServer: (name: string) =>
|
||||
fetchJSON<McpTestResult>(
|
||||
`/api/mcp/servers/${encodeURIComponent(name)}/test`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
// ── Admin: Pairing ──────────────────────────────────────────────────
|
||||
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
|
||||
approvePairing: (platform: string, code: string) =>
|
||||
fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ platform, code }),
|
||||
}),
|
||||
revokePairing: (platform: string, user_id: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/pairing/revoke", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ platform, user_id }),
|
||||
}),
|
||||
clearPendingPairing: () =>
|
||||
fetchJSON<{ ok: boolean; cleared: number }>("/api/pairing/clear-pending", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// ── Admin: Webhooks ─────────────────────────────────────────────────
|
||||
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
|
||||
createWebhook: (body: WebhookCreate) =>
|
||||
fetchJSON<WebhookRoute & { secret: string }>("/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteWebhook: (name: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// ── Admin: Credential pool ──────────────────────────────────────────
|
||||
getCredentialPool: () =>
|
||||
fetchJSON<{ providers: CredentialPoolProvider[] }>("/api/credentials/pool"),
|
||||
addCredentialPoolEntry: (
|
||||
provider: string,
|
||||
api_key: string,
|
||||
label?: string,
|
||||
) =>
|
||||
fetchJSON<{ ok: boolean; provider: string; count: number }>(
|
||||
"/api/credentials/pool",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, api_key, label }),
|
||||
},
|
||||
),
|
||||
removeCredentialPoolEntry: (provider: string, index: number) =>
|
||||
fetchJSON<{ ok: boolean; provider: string; count: number }>(
|
||||
`/api/credentials/pool/${encodeURIComponent(provider)}/${index}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// ── Admin: Memory provider ──────────────────────────────────────────
|
||||
getMemory: () => fetchJSON<MemoryStatus>("/api/memory"),
|
||||
setMemoryProvider: (provider: string) =>
|
||||
fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
}),
|
||||
resetMemory: (target: "all" | "memory" | "user") =>
|
||||
fetchJSON<{ ok: boolean; deleted: string[] }>("/api/memory/reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
|
||||
// ── Admin: Gateway lifecycle ────────────────────────────────────────
|
||||
startGateway: () =>
|
||||
fetchJSON<ActionResponse>("/api/gateway/start", { method: "POST" }),
|
||||
stopGateway: () =>
|
||||
fetchJSON<ActionResponse>("/api/gateway/stop", { method: "POST" }),
|
||||
|
||||
// ── Admin: Operations ───────────────────────────────────────────────
|
||||
runDoctor: () =>
|
||||
fetchJSON<ActionResponse>("/api/ops/doctor", { method: "POST" }),
|
||||
runSecurityAudit: () =>
|
||||
fetchJSON<ActionResponse>("/api/ops/security-audit", { method: "POST" }),
|
||||
runBackup: (output?: string) =>
|
||||
fetchJSON<ActionResponse>("/api/ops/backup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ output }),
|
||||
}),
|
||||
runImport: (archive: string) =>
|
||||
fetchJSON<ActionResponse>("/api/ops/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ archive }),
|
||||
}),
|
||||
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
|
||||
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
|
||||
pruneCheckpoints: () =>
|
||||
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
|
||||
|
||||
// ── Admin: Skills hub ───────────────────────────────────────────────
|
||||
installSkillFromHub: (identifier: string) =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identifier }),
|
||||
}),
|
||||
uninstallSkillFromHub: (name: string) =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
updateSkillsFromHub: () =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
|
||||
};
|
||||
|
||||
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
|
||||
@ -532,6 +663,132 @@ export interface ActionResponse {
|
||||
update_command?: string;
|
||||
}
|
||||
|
||||
// ── Admin types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
transport: "http" | "stdio" | "unknown";
|
||||
url: string | null;
|
||||
command: string | null;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
auth: string | null;
|
||||
enabled: boolean;
|
||||
tools: string[] | null;
|
||||
}
|
||||
|
||||
export interface McpServerCreate {
|
||||
name: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export interface McpTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
export interface PairingUser {
|
||||
platform: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
code?: string;
|
||||
age_minutes?: number;
|
||||
}
|
||||
|
||||
export interface PairingResponse {
|
||||
pending: PairingUser[];
|
||||
approved: PairingUser[];
|
||||
}
|
||||
|
||||
export interface WebhookRoute {
|
||||
name: string;
|
||||
description: string;
|
||||
events: string[];
|
||||
deliver: string;
|
||||
deliver_only: boolean;
|
||||
prompt: string;
|
||||
skills: string[];
|
||||
created_at: string | null;
|
||||
url: string;
|
||||
secret_set: boolean;
|
||||
}
|
||||
|
||||
export interface WebhooksResponse {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
subscriptions: WebhookRoute[];
|
||||
}
|
||||
|
||||
export interface WebhookCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
events?: string[];
|
||||
prompt?: string;
|
||||
skills?: string[];
|
||||
deliver?: string;
|
||||
deliver_only?: boolean;
|
||||
deliver_chat_id?: string;
|
||||
}
|
||||
|
||||
export interface CredentialPoolEntry {
|
||||
index: number;
|
||||
id: string | null;
|
||||
label: string | null;
|
||||
auth_type: string | null;
|
||||
source: string | null;
|
||||
priority: number;
|
||||
last_status: string | null;
|
||||
request_count: number;
|
||||
token_preview: string;
|
||||
has_refresh: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialPoolProvider {
|
||||
provider: string;
|
||||
entries: CredentialPoolEntry[];
|
||||
}
|
||||
|
||||
export interface MemoryProviderInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryStatus {
|
||||
active: string;
|
||||
providers: MemoryProviderInfo[];
|
||||
builtin_files: { memory: number; user: number };
|
||||
}
|
||||
|
||||
export interface HookEntry {
|
||||
event: string;
|
||||
matcher: string | null;
|
||||
command: string | null;
|
||||
timeout: number | null;
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export interface HooksResponse {
|
||||
hooks: HookEntry[];
|
||||
allowlist: string[];
|
||||
}
|
||||
|
||||
export interface CheckpointSession {
|
||||
session: string;
|
||||
files: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface CheckpointsResponse {
|
||||
sessions: CheckpointSession[];
|
||||
total_bytes: number;
|
||||
}
|
||||
|
||||
/** Per-call overrides for {@link fetchJSON}. */
|
||||
interface FetchJSONOptions {
|
||||
/** When true, a 401 response is surfaced as a normal thrown error rather
|
||||
|
||||
446
web/src/pages/McpPage.tsx
Normal file
446
web/src/pages/McpPage.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { 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 { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
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 { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
type Transport = "http" | "stdio";
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnv(raw: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) return;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (key) env[key] = value;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
|
||||
http: "success",
|
||||
stdio: "warning",
|
||||
unknown: "secondary",
|
||||
};
|
||||
|
||||
export default function McpPage() {
|
||||
const [servers, setServers] = useState<McpServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// Add server modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<Transport>("http");
|
||||
const [url, setUrl] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
|
||||
// Test results keyed by server name
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, McpTestResult>
|
||||
>({});
|
||||
|
||||
const loadServers = useCallback(() => {
|
||||
api
|
||||
.getMcpServers()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch((e) => showToast(`Error: ${e}`, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, [loadServers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
showToast("Name required", "error");
|
||||
return;
|
||||
}
|
||||
if (transport === "http" && !url.trim()) {
|
||||
showToast("URL required", "error");
|
||||
return;
|
||||
}
|
||||
if (transport === "stdio" && !command.trim()) {
|
||||
showToast("Command required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const body: McpServerCreate = { name: name.trim() };
|
||||
if (transport === "http") {
|
||||
body.url = url.trim();
|
||||
} else {
|
||||
body.command = command.trim();
|
||||
const argList = parseArgs(args);
|
||||
if (argList.length) body.args = argList;
|
||||
}
|
||||
const envMap = parseEnv(env);
|
||||
if (Object.keys(envMap).length) body.env = envMap;
|
||||
|
||||
await api.addMcpServer(body);
|
||||
showToast("Add ✓", "success");
|
||||
setName("");
|
||||
setUrl("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setEnv("");
|
||||
setTransport("http");
|
||||
setCreateModalOpen(false);
|
||||
loadServers();
|
||||
} catch (e) {
|
||||
showToast(`Failed to add: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (server: McpServer) => {
|
||||
setTesting(server.name);
|
||||
try {
|
||||
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",
|
||||
);
|
||||
} else {
|
||||
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const serverDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
await api.removeMcpServer(serverName);
|
||||
showToast(`Delete: "${truncateText(serverName, 30)}"`, "success");
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[serverName];
|
||||
return next;
|
||||
});
|
||||
loadServers();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadServers, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// Put "Add Server" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Add Server
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={serverDelete.isOpen}
|
||||
onCancel={serverDelete.cancel}
|
||||
onConfirm={serverDelete.confirm}
|
||||
title="Remove MCP server"
|
||||
description={
|
||||
serverDelete.pendingId
|
||||
? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
|
||||
: "This will remove the server."
|
||||
}
|
||||
loading={serverDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Add server modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
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 && setCreateModalOpen(false)
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-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={() => setCreateModalOpen(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
|
||||
id="create-mcp-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
Add MCP server
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-name">Name</Label>
|
||||
<Input
|
||||
id="mcp-name"
|
||||
autoFocus
|
||||
placeholder="my-server"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-transport">Transport</Label>
|
||||
<Select
|
||||
id="mcp-transport"
|
||||
value={transport}
|
||||
onValueChange={(v) => setTransport(v as Transport)}
|
||||
>
|
||||
<SelectOption value="http">HTTP/SSE</SelectOption>
|
||||
<SelectOption value="stdio">stdio</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transport === "http" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-command">Command</Label>
|
||||
<Input
|
||||
id="mcp-command"
|
||||
placeholder="npx"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-args">Args</Label>
|
||||
<Input
|
||||
id="mcp-args"
|
||||
placeholder="-y @modelcontextprotocol/server-foo"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-env">Environment (KEY=VALUE per line)</Label>
|
||||
<textarea
|
||||
id="mcp-env"
|
||||
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={"API_KEY=secret\nDEBUG=1"}
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : undefined}
|
||||
>
|
||||
{creating ? "Adding..." : "Add"}
|
||||
</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
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
MCP Servers ({servers.length})
|
||||
</H2>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{servers.map((server) => {
|
||||
const envCount = Object.keys(server.env ?? {}).length;
|
||||
const result = testResults[server.name];
|
||||
|
||||
return (
|
||||
<Card key={server.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">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
<Badge tone={TRANSPORT_TONE[server.transport] ?? "secondary"}>
|
||||
{server.transport}
|
||||
</Badge>
|
||||
{!server.enabled && (
|
||||
<Badge tone="outline">disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{server.transport === "http" ? (
|
||||
<span className="font-mono truncate">
|
||||
{server.url ?? "—"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono truncate">
|
||||
{[server.command, ...(server.args ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" ") || "—"}
|
||||
</span>
|
||||
)}
|
||||
{envCount > 0 && (
|
||||
<span>
|
||||
{envCount} env var{envCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<div className="mt-2 text-xs">
|
||||
{result.ok ? (
|
||||
<p className="text-success">
|
||||
{result.tools.length === 0
|
||||
? "Connected — no tools"
|
||||
: `Tools: ${result.tools
|
||||
.map((tool) => tool.name)
|
||||
.join(", ")}`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-destructive">
|
||||
{result.error ?? "Connection failed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
onClick={() => handleTest(server)}
|
||||
disabled={testing === server.name}
|
||||
>
|
||||
{testing === server.name ? <Spinner /> : <Zap />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
onClick={() => serverDelete.requestDelete(server.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
web/src/pages/PairingPage.tsx
Normal file
276
web/src/pages/PairingPage.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Check, ShieldCheck, Trash2, Users, X } from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PairingResponse, PairingUser } 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";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
function getUserKey(user: PairingUser): string {
|
||||
return `${user.platform}:${user.user_id}`;
|
||||
}
|
||||
|
||||
function splitUserKey(key: string): { platform: string; user_id: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { platform: "", user_id: key };
|
||||
return { platform: key.slice(0, idx), user_id: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function getUserLabel(user: PairingUser): string {
|
||||
return user.user_name || user.user_id;
|
||||
}
|
||||
|
||||
export default function PairingPage() {
|
||||
const [pending, setPending] = useState<PairingUser[]>([]);
|
||||
const [approved, setApproved] = useState<PairingUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [approving, setApproving] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
const loadPairing = useCallback(() => {
|
||||
api
|
||||
.getPairing()
|
||||
.then((res: PairingResponse) => {
|
||||
setPending(res.pending);
|
||||
setApproved(res.approved);
|
||||
})
|
||||
.catch(() => showToast("Failed to load pairing requests", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPairing();
|
||||
}, [loadPairing]);
|
||||
|
||||
const handleApprove = async (user: PairingUser) => {
|
||||
if (!user.code) {
|
||||
showToast("Missing pairing code", "error");
|
||||
return;
|
||||
}
|
||||
const key = getUserKey(user);
|
||||
setApproving(key);
|
||||
try {
|
||||
await api.approvePairing(user.platform, user.code);
|
||||
showToast(`Approved: "${getUserLabel(user)}"`, "success");
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setApproving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPending = async () => {
|
||||
if (!window.confirm("Clear all pending pairing requests?")) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
const res = await api.clearPendingPairing();
|
||||
showToast(`Cleared ${res.cleared} pending request(s)`, "success");
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userRevoke = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (key: string) => {
|
||||
const { platform, user_id } = splitUserKey(key);
|
||||
const user = approved.find((u) => getUserKey(u) === key);
|
||||
try {
|
||||
await api.revokePairing(platform, user_id);
|
||||
showToast(
|
||||
`Revoked: "${user ? getUserLabel(user) : user_id}"`,
|
||||
"success",
|
||||
);
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[approved, loadPairing, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// Put "Clear pending" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleClearPending}
|
||||
disabled={clearing}
|
||||
prefix={clearing ? <Spinner /> : <Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
Clear pending
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setEnd, clearing]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRevokeUser = userRevoke.pendingId
|
||||
? approved.find((u) => getUserKey(u) === userRevoke.pendingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={userRevoke.isOpen}
|
||||
onCancel={userRevoke.cancel}
|
||||
onConfirm={userRevoke.confirm}
|
||||
title="Revoke access"
|
||||
description={
|
||||
pendingRevokeUser
|
||||
? `"${getUserLabel(pendingRevokeUser)}" will lose access. This cannot be undone.`
|
||||
: "This user will lose access. This cannot be undone."
|
||||
}
|
||||
confirmLabel="Revoke"
|
||||
loading={userRevoke.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Pending requests */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Pending requests ({pending.length})
|
||||
</H2>
|
||||
|
||||
{pending.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No pending pairing requests
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pending.map((user) => {
|
||||
const key = getUserKey(user);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<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">
|
||||
<Badge tone="outline">{user.platform}</Badge>
|
||||
{user.code && (
|
||||
<span className="font-mono text-sm">{user.code}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="truncate">{user.user_id}</span>
|
||||
{user.user_name && (
|
||||
<span className="truncate">{user.user_name}</span>
|
||||
)}
|
||||
{typeof user.age_minutes === "number" && (
|
||||
<span>{user.age_minutes}m ago</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => handleApprove(user)}
|
||||
disabled={approving === key || !user.code}
|
||||
prefix={
|
||||
approving === key ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Approved users */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Approved users ({approved.length})
|
||||
</H2>
|
||||
|
||||
{approved.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No approved users
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{approved.map((user) => {
|
||||
const key = getUserKey(user);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<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">
|
||||
<Badge tone="outline">{user.platform}</Badge>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{user.user_id}
|
||||
</span>
|
||||
</div>
|
||||
{user.user_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{user.user_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Revoke"
|
||||
aria-label="Revoke"
|
||||
className="text-destructive"
|
||||
onClick={() => userRevoke.requestDelete(key)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
663
web/src/pages/SystemPage.tsx
Normal file
663
web/src/pages/SystemPage.tsx
Normal file
@ -0,0 +1,663 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
Database,
|
||||
KeyRound,
|
||||
Play,
|
||||
Power,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Stethoscope,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { 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 { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { 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 { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
StatusResponse,
|
||||
MemoryStatus,
|
||||
CredentialPoolProvider,
|
||||
CheckpointsResponse,
|
||||
HooksResponse,
|
||||
} 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function ActionLogViewer({
|
||||
action,
|
||||
onClose,
|
||||
}: {
|
||||
action: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [running, setRunning] = useState(true);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const st = await api.getActionStatus(action, 400);
|
||||
if (cancelled) return;
|
||||
setLines(st.lines);
|
||||
setRunning(st.running);
|
||||
setExitCode(st.exit_code);
|
||||
if (st.running) {
|
||||
timer.current = setTimeout(poll, 1200);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, [action]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm">{action}</span>
|
||||
{running ? (
|
||||
<Badge tone="warning">running</Badge>
|
||||
) : (
|
||||
<Badge tone={exitCode === 0 ? "success" : "destructive"}>
|
||||
{exitCode === 0 ? "done" : `exit ${exitCode}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button ghost size="icon" onClick={onClose} aria-label="Close log">
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-3 text-xs font-mono text-muted-foreground">
|
||||
{lines.length ? lines.join("\n") : "Starting…"}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
const [status, setStatus] = useState<StatusResponse | 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 [loading, setLoading] = useState(true);
|
||||
|
||||
// Which spawn-action log is currently shown (null = none).
|
||||
const [activeAction, setActiveAction] = useState<string | null>(null);
|
||||
|
||||
// Add-credential form.
|
||||
const [credProvider, setCredProvider] = useState("openrouter");
|
||||
const [credKey, setCredKey] = useState("");
|
||||
const [credLabel, setCredLabel] = useState("");
|
||||
const [addingCred, setAddingCred] = useState(false);
|
||||
|
||||
// Import archive path.
|
||||
const [importPath, setImportPath] = useState("");
|
||||
|
||||
const loadAll = useCallback(() => {
|
||||
Promise.allSettled([
|
||||
api.getStatus(),
|
||||
api.getMemory(),
|
||||
api.getCredentialPool(),
|
||||
api.getCheckpoints(),
|
||||
api.getHooks(),
|
||||
])
|
||||
.then(([s, m, p, c, h]) => {
|
||||
if (s.status === "fulfilled") setStatus(s.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);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// ── Gateway lifecycle ──────────────────────────────────────────────
|
||||
const runGateway = async (
|
||||
verb: "start" | "stop" | "restart",
|
||||
) => {
|
||||
try {
|
||||
if (verb === "start") {
|
||||
await api.startGateway();
|
||||
setActiveAction("gateway-start");
|
||||
} else if (verb === "stop") {
|
||||
await api.stopGateway();
|
||||
setActiveAction("gateway-stop");
|
||||
} else {
|
||||
await api.restartGateway();
|
||||
setActiveAction("gateway-restart");
|
||||
}
|
||||
showToast(`Gateway ${verb} started`, "success");
|
||||
setTimeout(loadAll, 3000);
|
||||
} catch (e) {
|
||||
showToast(`Gateway ${verb} failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Memory ─────────────────────────────────────────────────────────
|
||||
const setMemoryProvider = async (provider: string) => {
|
||||
try {
|
||||
await api.setMemoryProvider(provider);
|
||||
showToast(
|
||||
`Memory provider: ${provider || "built-in only"}`,
|
||||
"success",
|
||||
);
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
showToast(`Failed to set provider: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const memoryReset = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (target: string) => {
|
||||
try {
|
||||
const res = await api.resetMemory(
|
||||
target as "all" | "memory" | "user",
|
||||
);
|
||||
showToast(`Reset: ${res.deleted.join(", ") || "nothing"}`, "success");
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
showToast(`Reset failed: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadAll, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// ── Credential pool ────────────────────────────────────────────────
|
||||
const addCredential = async () => {
|
||||
if (!credProvider.trim() || !credKey.trim()) {
|
||||
showToast("Provider and API key required", "error");
|
||||
return;
|
||||
}
|
||||
setAddingCred(true);
|
||||
try {
|
||||
await api.addCredentialPoolEntry(
|
||||
credProvider.trim(),
|
||||
credKey.trim(),
|
||||
credLabel.trim() || undefined,
|
||||
);
|
||||
showToast("Credential added", "success");
|
||||
setCredKey("");
|
||||
setCredLabel("");
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
showToast(`Failed to add credential: ${e}`, "error");
|
||||
} finally {
|
||||
setAddingCred(false);
|
||||
}
|
||||
};
|
||||
|
||||
const credDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (key: string) => {
|
||||
const [provider, idxStr] = key.split("|");
|
||||
try {
|
||||
await api.removeCredentialPoolEntry(provider, Number(idxStr));
|
||||
showToast("Credential removed", "success");
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
showToast(`Failed to remove: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadAll, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// ── Operations ─────────────────────────────────────────────────────
|
||||
const runOp = async (
|
||||
fn: () => Promise<{ name: string }>,
|
||||
label: string,
|
||||
) => {
|
||||
try {
|
||||
const res = await fn();
|
||||
setActiveAction(res.name);
|
||||
showToast(`${label} started`, "success");
|
||||
} catch (e) {
|
||||
showToast(`${label} failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const checkpointsPrune = useConfirmDelete({
|
||||
onDelete: useCallback(async () => {
|
||||
try {
|
||||
const res = await api.pruneCheckpoints();
|
||||
setActiveAction(res.name);
|
||||
showToast("Checkpoint prune started", "success");
|
||||
} catch (e) {
|
||||
showToast(`Prune failed: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
}, [showToast]),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gatewayRunning = status?.gateway_running;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={memoryReset.isOpen}
|
||||
onCancel={memoryReset.cancel}
|
||||
onConfirm={memoryReset.confirm}
|
||||
title="Reset memory"
|
||||
description="This permanently erases the selected built-in memory files. This cannot be undone."
|
||||
loading={memoryReset.isDeleting}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={credDelete.isOpen}
|
||||
onCancel={credDelete.cancel}
|
||||
onConfirm={credDelete.confirm}
|
||||
title="Remove credential"
|
||||
description="Remove this pooled API key? The agent will no longer rotate through it."
|
||||
loading={credDelete.isDeleting}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={checkpointsPrune.isOpen}
|
||||
onCancel={checkpointsPrune.cancel}
|
||||
onConfirm={checkpointsPrune.confirm}
|
||||
title="Prune checkpoints"
|
||||
description="Delete the rollback checkpoint shadow store? Existing /rollback points will be lost."
|
||||
loading={checkpointsPrune.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Live action log */}
|
||||
{activeAction && (
|
||||
<ActionLogViewer
|
||||
action={activeAction}
|
||||
onClose={() => setActiveAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Gateway ───────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<Power className="h-4 w-4" /> Gateway
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={gatewayRunning ? "success" : "secondary"}>
|
||||
{gatewayRunning ? "running" : "stopped"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{status?.gateway_state ?? "—"}
|
||||
{status?.gateway_pid ? ` · pid ${status.gateway_pid}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => runGateway("start")}
|
||||
disabled={gatewayRunning}
|
||||
prefix={<Play className="h-3.5 w-3.5" />}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => runGateway("restart")}
|
||||
prefix={<RotateCw className="h-3.5 w-3.5" />}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase text-warning"
|
||||
ghost
|
||||
onClick={() => runGateway("stop")}
|
||||
disabled={!gatewayRunning}
|
||||
prefix={<Power className="h-3.5 w-3.5" />}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Memory ────────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<Brain className="h-4 w-4" /> Memory
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-4">
|
||||
<div className="grid gap-2 max-w-sm">
|
||||
<Label htmlFor="mem-provider">External provider</Label>
|
||||
<Select
|
||||
id="mem-provider"
|
||||
value={memory?.active || ""}
|
||||
onValueChange={setMemoryProvider}
|
||||
>
|
||||
<SelectOption value="">Built-in only</SelectOption>
|
||||
{(memory?.providers ?? []).map((p) => (
|
||||
<SelectOption key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.configured ? " (configured)" : ""}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set up a new provider's credentials with{" "}
|
||||
<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:{" "}
|
||||
{formatBytes(memory?.builtin_files.memory ?? 0)} · USER.md:{" "}
|
||||
{formatBytes(memory?.builtin_files.user ?? 0)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
className="text-destructive"
|
||||
onClick={() => memoryReset.requestDelete("memory")}
|
||||
>
|
||||
Reset MEMORY.md
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
className="text-destructive"
|
||||
onClick={() => memoryReset.requestDelete("user")}
|
||||
>
|
||||
Reset USER.md
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
className="text-destructive"
|
||||
onClick={() => memoryReset.requestDelete("all")}
|
||||
>
|
||||
Reset all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Credential pool ───────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="h-4 w-4" /> Credential pool
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-provider">Provider</Label>
|
||||
<Input
|
||||
id="cred-provider"
|
||||
value={credProvider}
|
||||
onChange={(e) => setCredProvider(e.target.value)}
|
||||
placeholder="openrouter"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:col-span-2">
|
||||
<Label htmlFor="cred-key">API key</Label>
|
||||
<Input
|
||||
id="cred-key"
|
||||
type="password"
|
||||
value={credKey}
|
||||
onChange={(e) => setCredKey(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cred-label">Label</Label>
|
||||
<Input
|
||||
id="cred-label"
|
||||
value={credLabel}
|
||||
onChange={(e) => setCredLabel(e.target.value)}
|
||||
placeholder="optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={addCredential}
|
||||
disabled={addingCred}
|
||||
prefix={addingCred ? <Spinner /> : undefined}
|
||||
>
|
||||
Add key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pool.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No pooled credentials. Add one above to enable key rotation.
|
||||
</p>
|
||||
)}
|
||||
{pool.map((prov) => (
|
||||
<div key={prov.provider} className="flex flex-col gap-2">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{prov.provider}
|
||||
</span>
|
||||
{prov.entries.map((entry) => (
|
||||
<div
|
||||
key={`${prov.provider}-${entry.index}`}
|
||||
className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{entry.token_preview}
|
||||
</span>
|
||||
<Badge tone="outline">{entry.auth_type}</Badge>
|
||||
{entry.last_status && (
|
||||
<Badge tone="secondary">{entry.last_status}</Badge>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="ml-auto text-destructive"
|
||||
aria-label="Remove credential"
|
||||
onClick={() =>
|
||||
credDelete.requestDelete(
|
||||
`${prov.provider}|${entry.index}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Operations ────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<Activity className="h-4 w-4" /> Operations
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap gap-2 py-4">
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
prefix={<Stethoscope className="h-3.5 w-3.5" />}
|
||||
onClick={() => runOp(api.runDoctor, "Doctor")}
|
||||
>
|
||||
Run doctor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
prefix={<ShieldCheck className="h-3.5 w-3.5" />}
|
||||
onClick={() => runOp(api.runSecurityAudit, "Security audit")}
|
||||
>
|
||||
Security audit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
prefix={<Database className="h-3.5 w-3.5" />}
|
||||
onClick={() => runOp(() => api.runBackup(), "Backup")}
|
||||
>
|
||||
Backup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
prefix={<RotateCw className="h-3.5 w-3.5" />}
|
||||
onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}
|
||||
>
|
||||
Update skills
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={!importPath.trim()}
|
||||
onClick={() => {
|
||||
if (!importPath.trim()) return;
|
||||
runOp(() => api.runImport(importPath.trim()), "Import");
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Checkpoints ───────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<Database className="h-4 w-4" /> Checkpoints
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{checkpoints?.sessions.length ?? 0} session(s) ·{" "}
|
||||
{formatBytes(checkpoints?.total_bytes ?? 0)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
className="text-destructive"
|
||||
disabled={!checkpoints?.sessions.length}
|
||||
prefix={<Trash2 className="h-3.5 w-3.5" />}
|
||||
onClick={() => checkpointsPrune.requestDelete("all")}
|
||||
>
|
||||
Prune
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── 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>
|
||||
{(!hooks || hooks.hooks.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
No shell hooks configured.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{hooks?.hooks.map((h, i) => (
|
||||
<Card key={`${h.event}-${i}`}>
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Badge tone="outline">{h.event}</Badge>
|
||||
{h.matcher && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
matcher: {h.matcher}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{h.command}
|
||||
</span>
|
||||
<Badge tone={h.allowed ? "success" : "warning"}>
|
||||
{h.allowed ? "allowed" : "not approved"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
web/src/pages/WebhooksPage.tsx
Normal file
447
web/src/pages/WebhooksPage.tsx
Normal file
@ -0,0 +1,447 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Webhook, Plus, Trash2, X, Copy, Check } 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 { WebhookRoute, WebhooksResponse } 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";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface CreatedWebhook {
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [value]);
|
||||
return (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Copy"
|
||||
aria-label="Copy"
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const [data, setData] = useState<WebhooksResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// New subscription modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [events, setEvents] = useState("");
|
||||
const [deliver, setDeliver] = useState("log");
|
||||
const [deliverOnly, setDeliverOnly] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedWebhook | null>(null);
|
||||
|
||||
const closeCreateModal = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setCreated(null);
|
||||
}, []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
|
||||
const enabled = data?.enabled ?? false;
|
||||
const subscriptions = data?.subscriptions ?? [];
|
||||
|
||||
const loadWebhooks = useCallback(() => {
|
||||
api
|
||||
.getWebhooks()
|
||||
.then(setData)
|
||||
.catch(() => showToast("Failed to load webhooks", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWebhooks();
|
||||
}, [loadWebhooks]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setEvents("");
|
||||
setDeliver("log");
|
||||
setDeliverOnly(false);
|
||||
setPrompt("");
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
showToast("Name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const eventsList = events
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
const res = await api.createWebhook({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
events: eventsList.length ? eventsList : undefined,
|
||||
deliver,
|
||||
deliver_only: deliverOnly,
|
||||
prompt: prompt.trim() || undefined,
|
||||
});
|
||||
showToast("Created ✓", "success");
|
||||
setCreated({ url: res.url, secret: res.secret });
|
||||
resetForm();
|
||||
loadWebhooks();
|
||||
} catch (e) {
|
||||
showToast(`Failed to create: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const webhookDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
await api.deleteWebhook(name);
|
||||
showToast(`Deleted: "${name}"`, "success");
|
||||
loadWebhooks();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadWebhooks, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// Put "New subscription" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
disabled={!enabled}
|
||||
prefix={<Plus />}
|
||||
onClick={() => {
|
||||
setCreated(null);
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New subscription
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, enabled, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingName = webhookDelete.pendingId ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={webhookDelete.isOpen}
|
||||
onCancel={webhookDelete.cancel}
|
||||
onConfirm={webhookDelete.confirm}
|
||||
title="Delete webhook"
|
||||
description={
|
||||
pendingName
|
||||
? `"${pendingName}" — this will permanently remove this webhook subscription.`
|
||||
: "This will permanently remove this webhook subscription."
|
||||
}
|
||||
loading={webhookDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Create subscription modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
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 && closeCreateModal()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-webhook-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={closeCreateModal}
|
||||
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="create-webhook-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
New subscription
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{created ? (
|
||||
<div className="p-5 grid gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Subscription created. Copy the secret now — it is only shown
|
||||
once.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Webhook URL</Label>
|
||||
<div className="flex items-center gap-2 border border-border bg-background/40 px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
||||
{created.url}
|
||||
</span>
|
||||
<CopyButton value={created.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Secret (shown once)</Label>
|
||||
<div className="flex items-center gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
||||
{created.secret}
|
||||
</span>
|
||||
<CopyButton value={created.secret} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-name">Name</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
autoFocus
|
||||
placeholder="e.g. github-push"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-description">Description</Label>
|
||||
<Input
|
||||
id="webhook-description"
|
||||
placeholder="What this webhook does (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-events">Events</Label>
|
||||
<Input
|
||||
id="webhook-events"
|
||||
placeholder="comma-separated, leave empty for all"
|
||||
value={events}
|
||||
onChange={(e) => setEvents(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-deliver">Deliver to</Label>
|
||||
<Select
|
||||
id="webhook-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="log">Log</SelectOption>
|
||||
<SelectOption value="telegram">Telegram</SelectOption>
|
||||
<SelectOption value="discord">Discord</SelectOption>
|
||||
<SelectOption value="slack">Slack</SelectOption>
|
||||
<SelectOption value="email">Email</SelectOption>
|
||||
<SelectOption value="github_comment">
|
||||
GitHub comment
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-deliver-only">Deliver only</Label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground h-9">
|
||||
<input
|
||||
id="webhook-deliver-only"
|
||||
type="checkbox"
|
||||
checked={deliverOnly}
|
||||
onChange={(e) => setDeliverOnly(e.target.checked)}
|
||||
/>
|
||||
Skip the agent, deliver payload directly
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-prompt">Prompt</Label>
|
||||
<textarea
|
||||
id="webhook-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="Instructions for the agent when this webhook fires (optional)"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : undefined}
|
||||
>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && (
|
||||
<Card>
|
||||
<CardContent className="py-6 flex items-start gap-3 text-sm">
|
||||
<Webhook className="h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Webhook platform disabled</span>
|
||||
<span className="text-muted-foreground">
|
||||
The webhook platform must be enabled in your messaging settings
|
||||
before you can create subscriptions. Enable it, then return to
|
||||
this page.
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Webhook className="h-4 w-4" />
|
||||
Subscriptions ({subscriptions.length})
|
||||
</H2>
|
||||
|
||||
{subscriptions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No webhook subscriptions yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{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="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{sub.name}
|
||||
</span>
|
||||
<Badge tone="outline">{sub.deliver}</Badge>
|
||||
{sub.deliver_only && (
|
||||
<Badge tone="secondary">deliver only</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sub.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{sub.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap mb-2">
|
||||
{sub.events.length === 0 ? (
|
||||
<Badge tone="secondary">(all)</Badge>
|
||||
) : (
|
||||
sub.events.map((evt) => (
|
||||
<Badge key={evt} tone="secondary">
|
||||
{evt}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex-1 min-w-0 truncate font-mono">
|
||||
{sub.url}
|
||||
</span>
|
||||
<CopyButton value={sub.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
onClick={() => webhookDelete.requestDelete(sub.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: "Web Dashboard"
|
||||
description: "Browser-based dashboard for managing configuration, API keys, sessions, logs, analytics, cron jobs, and skills"
|
||||
description: "Browser-based administration panel for managing configuration, API keys, MCP servers, messaging pairing, webhooks, the gateway, memory, credentials, sessions, logs, analytics, cron jobs, and skills"
|
||||
---
|
||||
|
||||
# Web Dashboard
|
||||
@ -185,6 +185,46 @@ Browse, search, and toggle skills and toolsets. Skills are loaded from `~/.herme
|
||||
- **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
|
||||
|
||||
### MCP
|
||||
|
||||
Manage [MCP](/integrations/mcp) servers without the CLI. The same `mcp_servers`
|
||||
block in `config.yaml` that `hermes mcp` reads from.
|
||||
|
||||
- **Add** — register an HTTP/SSE server (URL) or a stdio server (command + args), with optional `KEY=VALUE` environment variables for stdio servers
|
||||
- **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
|
||||
|
||||
### Webhooks
|
||||
|
||||
Manage dynamic [webhook subscriptions](/user-guide/messaging/webhooks). The
|
||||
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.
|
||||
- **List** — each subscription shows its URL, events, and delivery target
|
||||
- **Delete** — remove a subscription (hot-reloaded by the gateway, no restart needed)
|
||||
|
||||
### Pairing
|
||||
|
||||
Approve and revoke messaging users without the CLI — how a remote admin
|
||||
onboards Telegram/Discord/etc. users to a paired gateway.
|
||||
|
||||
- **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:
|
||||
|
||||
- **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.
|
||||
- **Checkpoints** — see the `/rollback` shadow store size and prune it
|
||||
- **Shell hooks** — read-only list of configured hooks with their consent-allowlist status
|
||||
|
||||
:::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.
|
||||
:::
|
||||
@ -300,6 +340,36 @@ Enables or disables a skill. Body: `{"name": "skill-name", "enabled": true}`.
|
||||
|
||||
Returns all toolsets with their label, description, tools list, and active/configured status.
|
||||
|
||||
### Admin endpoints
|
||||
|
||||
These power the MCP, Webhooks, Pairing, and System pages. All sit behind the
|
||||
same auth gate as the rest of `/api/`.
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---------------|---------|
|
||||
| `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 |
|
||||
| `DELETE /api/mcp/servers/{name}` | Remove a server |
|
||||
| `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}` |
|
||||
| `POST /api/pairing/clear-pending` | Drop all pending codes |
|
||||
| `GET /api/webhooks` | List subscriptions + platform-enabled status |
|
||||
| `POST /api/webhooks` | Create a subscription (returns one-time secret) |
|
||||
| `DELETE /api/webhooks/{name}` | Remove a subscription |
|
||||
| `GET /api/credentials/pool` | List pooled rotation keys (redacted) |
|
||||
| `POST /api/credentials/pool` | Add a key. Body: `{provider, api_key, label?}` |
|
||||
| `DELETE /api/credentials/pool/{provider}/{index}` | Remove a key (1-based index) |
|
||||
| `GET /api/memory` | Active provider + available providers + built-in file sizes |
|
||||
| `PUT /api/memory/provider` | Select a provider (empty = built-in only) |
|
||||
| `POST /api/memory/reset` | Reset built-in memory. Body: `{target: all\|memory\|user}` |
|
||||
| `POST /api/gateway/start` · `/stop` · `/restart` | Gateway lifecycle (backgrounded) |
|
||||
| `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/skills/hub/install` · `/uninstall` · `/update` | Skills hub actions (backgrounded) |
|
||||
|
||||
## OAuth Authentication (gated mode)
|
||||
|
||||
When the dashboard is bound to a public address — anything other than `127.0.0.1` / `localhost` — Hermes Agent engages an OAuth-based auth gate. Every request must carry a verified session cookie or it's bounced through a full OAuth round-trip via the Nous Portal.
|
||||
|
||||
Reference in New Issue
Block a user