feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats
Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
/api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
uptime/process, stdlib fallback
All gated by dashboard auth; secrets never returned.
* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats
- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats
* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle
Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.
* docs(dashboard): document the comprehensive admin pass + fresh screenshots
Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.
* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops
Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
/api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
(backgrounded, tailed via action status)
Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.
* feat(dashboard): curator + portal + diagnostics UI, tests
- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
Skill curator card (status + pause/resume + run now), and three new
Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
coverage for the new GET endpoints (31 tests total)
* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots
Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.
* feat(dashboard): session stats/export/prune + skills hub search endpoints
Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
POST /api/sessions/prune. /stats is registered before /{session_id} so the
literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)
* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit
Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
install by identifier with a live install log, and 'Update all' (mirrors
hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
updateCronJob, searchSkillsHub + types
Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.
* test(dashboard): cover session stats/rename/export/prune + skills hub search
Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.
* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit
Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
This commit is contained in:
@@ -74,6 +74,35 @@ class TestMcpEndpoints:
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_disable_toggle(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
|
||||
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
srv = [
|
||||
s for s in self.client.get("/api/mcp/servers").json()["servers"]
|
||||
if s["name"] == "tog"
|
||||
][0]
|
||||
assert srv["enabled"] is False
|
||||
# Toggling a missing server is a 404.
|
||||
assert self.client.put(
|
||||
"/api/mcp/servers/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
def test_catalog_lists_entries(self):
|
||||
r = self.client.get("/api/mcp/catalog")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "entries" in body and "diagnostics" in body
|
||||
# The shipped optional-mcps/ catalog has at least one entry; each must
|
||||
# carry the install/enabled status fields the UI relies on.
|
||||
for e in body["entries"]:
|
||||
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
|
||||
|
||||
def test_catalog_install_unknown_404(self):
|
||||
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestCredentialPoolEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -190,6 +219,40 @@ class TestOpsEndpoints:
|
||||
save_config(cfg)
|
||||
data = self.client.get("/api/ops/hooks").json()
|
||||
assert data["hooks"][0]["command"] == "/bin/echo hi"
|
||||
assert "valid_events" in data and len(data["valid_events"]) >= 1
|
||||
|
||||
def test_hook_create_and_delete(self):
|
||||
# Create with consent approval.
|
||||
r = self.client.post(
|
||||
"/api/ops/hooks",
|
||||
json={
|
||||
"event": "pre_tool_call",
|
||||
"command": "/bin/echo created",
|
||||
"matcher": "terminal",
|
||||
"timeout": 7,
|
||||
"approve": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["approved"] is True
|
||||
|
||||
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
created = [h for h in hooks if h["command"] == "/bin/echo created"]
|
||||
assert created and created[0]["allowed"] is True
|
||||
|
||||
# Unknown event rejected.
|
||||
assert self.client.post(
|
||||
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
|
||||
).status_code == 400
|
||||
|
||||
# Delete it.
|
||||
r = self.client.request(
|
||||
"DELETE",
|
||||
"/api/ops/hooks",
|
||||
json={"event": "pre_tool_call", "command": "/bin/echo created"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
|
||||
|
||||
def test_checkpoints_list_empty(self):
|
||||
data = self.client.get("/api/ops/checkpoints").json()
|
||||
@@ -200,6 +263,131 @@ class TestOpsEndpoints:
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestSystemStatsEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_stats_shape(self):
|
||||
r = self.client.get("/api/system/stats")
|
||||
assert r.status_code == 200
|
||||
s = r.json()
|
||||
# Identity fields always present (stdlib-sourced).
|
||||
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
|
||||
assert key in s and s[key]
|
||||
# psutil flag tells the UI whether the richer metrics are populated.
|
||||
assert "psutil" in s
|
||||
|
||||
|
||||
class TestCuratorEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_and_pause_toggle(self):
|
||||
r = self.client.get("/api/curator")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"enabled", "paused", "interval_hours"} <= set(body)
|
||||
# Pause then resume; the read reflects the write.
|
||||
r = self.client.put("/api/curator/paused", json={"paused": True})
|
||||
assert r.status_code == 200 and r.json()["paused"] is True
|
||||
assert self.client.get("/api/curator").json()["paused"] is True
|
||||
r = self.client.put("/api/curator/paused", json={"paused": False})
|
||||
assert r.status_code == 200 and r.json()["paused"] is False
|
||||
|
||||
|
||||
class TestPortalEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_shape(self):
|
||||
r = self.client.get("/api/portal")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
|
||||
assert isinstance(body["features"], list)
|
||||
|
||||
|
||||
class TestSessionManagementEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db.create_session(session_id="sess-x", source="cli")
|
||||
db.close()
|
||||
|
||||
def test_stats_not_shadowed_by_session_id_route(self):
|
||||
# /api/sessions/stats must resolve to the stats handler, not be captured
|
||||
# as {session_id}="stats" by the parameterized route registered after it.
|
||||
r = self.client.get("/api/sessions/stats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
|
||||
assert body["total"] >= 1
|
||||
|
||||
def test_rename(self):
|
||||
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
|
||||
assert r.status_code == 200 and r.json()["title"] == "Renamed"
|
||||
|
||||
def test_export(self):
|
||||
r = self.client.get("/api/sessions/sess-x/export")
|
||||
assert r.status_code == 200 and "messages" in r.json()
|
||||
assert self.client.get("/api/sessions/nope/export").status_code == 404
|
||||
|
||||
def test_prune_validation(self):
|
||||
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
|
||||
assert r.status_code == 200 and "removed" in r.json()
|
||||
assert self.client.post(
|
||||
"/api/sessions/prune", json={"older_than_days": 0}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestSkillsHubSearchEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
# Empty query short-circuits (no network) and returns no results.
|
||||
r = self.client.get("/api/skills/hub/search?q=")
|
||||
assert r.status_code == 200 and r.json() == {"results": []}
|
||||
|
||||
|
||||
|
||||
|
||||
class TestWebhookToggleEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
# Enable the webhook platform so a subscription can be created.
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("platforms", {})["webhook"] = {
|
||||
"enabled": True,
|
||||
"extra": {"host": "0.0.0.0", "port": 8644},
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
def test_create_toggle_disable(self):
|
||||
r = self.client.post(
|
||||
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["enabled"] is True
|
||||
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
subs = self.client.get("/api/webhooks").json()["subscriptions"]
|
||||
assert subs[0]["enabled"] is False
|
||||
assert self.client.put(
|
||||
"/api/webhooks/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
@@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate:
|
||||
"/api/memory",
|
||||
"/api/ops/hooks",
|
||||
"/api/ops/checkpoints",
|
||||
"/api/curator",
|
||||
"/api/portal",
|
||||
"/api/system/stats",
|
||||
],
|
||||
)
|
||||
def test_gated(self, path):
|
||||
|
||||
Reference in New Issue
Block a user