Skip to content

Commit c820458

Browse files
Zawwarsami16claude
andcommitted
phase 8.0: hub UI dashboard at / + /api/dashboard
new zhub/dashboard.html: dark, monospace, signal-rich operator view. auto-refreshes every 3s by polling /api/dashboard. shows: - top stats (publishers, connections, exposures, peers, total chats, average latency) - publishers table (name, brain/description, chat count, avg/max latency in ms, connection count, uptime) - exposures table (name, capabilities, uptime, public/private) - recent requests log (last 50, newest first; method + path + status color-coded by 2xx/4xx/5xx + latency_ms + ai tag) - federation peers from ZHUB_PEERS new endpoint GET /api/dashboard: JSON snapshot — publishers, exposures, by_ai metrics (with avg_latency_ms computed), peers, and recent_requests pulled from a new 50-entry ring buffer on the hub. populated by the existing access-log middleware (one entry per request: ts, method, path, status, latency_ms, ai_name when known). GET / replaced: now serves dashboard.html instead of the minimal inline registry table. /registry stays as a JSON discovery endpoint for API consumers — no breaking change. removed dead code: _render_registry_html function (~50 LOC). tests: - dashboard.html served with correct content-type at / - /api/dashboard returns full snapshot with all top-level keys - recent_requests ring buffer captures access-log entries bumped parallel-tool-call test threshold to 1.55s (was 1.3s) — slow proot envs occasionally take 1.4–1.5s for the parallel resolve; serial would still be > 1.6s + overhead so the parallelism guarantee remains testable. 147/147 pytest now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de8fc3d commit c820458

6 files changed

Lines changed: 484 additions & 87 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ CI runs the Python suite on 3.10 / 3.11 / 3.12 plus the JS module test on every
348348
| **7.1** | Per-exposure access policies (whitelist of AI names / publisher keys) |
349349
| **More brains** | Cohere, Mistral, Together, Bedrock, Vertex, vLLM-direct |
350350
| **MCP resources + prompts** | Surface zhub-served files & prompts to MCP hosts, beyond just tools |
351-
| **Hub UI dashboard** | Live view of connected publishers, recent requests, latency histograms, exposed devices |
351+
| ~~**Hub UI dashboard**~~| Live view of connected publishers, recent requests, latency, exposed devices — at `/` (Phase 8.0) |
352352
| **Multi-tier API keys** | Read / full / admin tiers per AI |
353353
| **Federation v2** | Signed peer relationships, shared identity registry across federated hubs |
354354

tests/test_dashboard.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Phase 8.0 — Hub UI dashboard.
2+
3+
`GET /` serves an HTML page with live operator visibility.
4+
`GET /api/dashboard` returns the JSON snapshot the page polls.
5+
The hub maintains a recent_requests ring buffer (last 50) populated
6+
by the access-log middleware so the dashboard can show recent traffic.
7+
"""
8+
9+
import asyncio
10+
import socket
11+
import threading
12+
import time
13+
14+
import pytest
15+
16+
try:
17+
import fastapi # noqa
18+
import uvicorn # noqa
19+
import httpx # noqa
20+
DEPS_AVAILABLE = True
21+
except ImportError:
22+
DEPS_AVAILABLE = False
23+
24+
if DEPS_AVAILABLE:
25+
from zhub.server import create_app
26+
from zhub import publish, expose
27+
28+
29+
def _free_port() -> int:
30+
with socket.socket() as s:
31+
s.bind(("", 0))
32+
return s.getsockname()[1]
33+
34+
35+
@pytest.fixture
36+
def hub():
37+
if not DEPS_AVAILABLE:
38+
pytest.skip("fastapi/uvicorn/httpx not installed")
39+
port = _free_port()
40+
app = create_app()
41+
42+
def run():
43+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
44+
log_level="warning")
45+
asyncio.run(uvicorn.Server(config).serve())
46+
47+
threading.Thread(target=run, daemon=True).start()
48+
for _ in range(30):
49+
try:
50+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
51+
break
52+
except OSError:
53+
time.sleep(0.1)
54+
yield port
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_dashboard_html_served_at_root(hub):
59+
async with httpx.AsyncClient(timeout=5.0) as c:
60+
r = await c.get(f"http://127.0.0.1:{hub}/")
61+
assert r.status_code == 200
62+
assert "text/html" in r.headers["content-type"].lower()
63+
body = r.text
64+
# Page must reference the data endpoint and have basic identity
65+
assert "/api/dashboard" in body
66+
assert "zhub" in body.lower()
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_api_dashboard_returns_full_snapshot(hub):
71+
pub = publish(
72+
name="dash-bot",
73+
description="dashboard test",
74+
chat_handler=lambda m, o: "ok",
75+
hub_url=f"ws://127.0.0.1:{hub}",
76+
public=True,
77+
)
78+
for _ in range(50):
79+
if pub.api_key:
80+
break
81+
await asyncio.sleep(0.1)
82+
e = expose(
83+
name="dash-cap",
84+
capabilities={"thing": ({"type": "object"}, lambda a: {"ok": True})},
85+
hub_url=f"ws://127.0.0.1:{hub}",
86+
public=True,
87+
)
88+
for _ in range(50):
89+
if e.exposure_id:
90+
break
91+
await asyncio.sleep(0.1)
92+
93+
async with httpx.AsyncClient(timeout=5.0) as c:
94+
r = await c.get(f"http://127.0.0.1:{hub}/api/dashboard")
95+
assert r.status_code == 200
96+
data = r.json()
97+
# Top-level keys
98+
for k in ("hub_id", "uptime_seconds", "publishers", "exposures",
99+
"by_ai", "recent_requests", "peers"):
100+
assert k in data, f"missing top-level key {k!r}: {data.keys()}"
101+
# Publishers must include ours
102+
pub_names = {p["name"] for p in data["publishers"]}
103+
assert "dash-bot" in pub_names
104+
# Exposures must include ours
105+
exp_names = {e_["name"] for e_ in data["exposures"]}
106+
assert "dash-cap" in exp_names
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_recent_requests_captured_in_dashboard(hub):
111+
async with httpx.AsyncClient(timeout=5.0) as c:
112+
# Generate a few hits
113+
await c.get(f"http://127.0.0.1:{hub}/healthz")
114+
await c.get(f"http://127.0.0.1:{hub}/registry")
115+
await c.get(f"http://127.0.0.1:{hub}/healthz")
116+
data = (await c.get(f"http://127.0.0.1:{hub}/api/dashboard")).json()
117+
118+
rr = data["recent_requests"]
119+
assert isinstance(rr, list)
120+
assert len(rr) >= 3
121+
# Each entry has the access-log fields
122+
sample = rr[-1]
123+
for k in ("ts", "method", "path", "status", "latency_ms"):
124+
assert k in sample, f"missing {k!r} in {sample!r}"
125+
paths = [r["path"] for r in rr]
126+
assert "/healthz" in paths
127+
assert "/registry" in paths

tests/test_parallel_tool_calls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,6 @@ async def slow_b(_args):
131131
assert {a["name"] for a in audit} == {"slow_a", "slow_b"}
132132
assert {a["tool_call_id"] for a in audit} == {"call_a", "call_b"}
133133
# Two 0.8s sleeps. Serial = 1.6s + overhead. Parallel = 0.8s + overhead.
134-
# Threshold 1.3s catches serial without flakiness on slow CI.
135-
assert elapsed < 1.3, f"parallel resolution should finish under 1.3s; took {elapsed:.2f}s"
134+
# Threshold 1.55s still catches a serial implementation (which would
135+
# land north of 1.6s + overhead) while leaving headroom on slow CI.
136+
assert elapsed < 1.55, f"parallel resolution should finish under 1.55s; took {elapsed:.2f}s"

0 commit comments

Comments
 (0)