# Cluster Dashboard (turnstone-console) `turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table and subscribes to each node's SSE event stream for real-time workstream, health, and metric updates. The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes. ## Architecture > See also: [Console Data Flow diagram](diagrams/png/11-console-data-flow.png) ``` ┌── services table ── turnstone-server │ (node registry) (per node) turnstone-console ──────┤ (one instance) │ └── turnstone-server (direct HTTP proxy) │ ▼ Browser ``` Data flows in two directions: - **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It opens a persistent SSE connection to each node's `GET /v1/api/events/global` endpoint, receiving a full snapshot on connect followed by real-time delta events (state changes, health transitions, aggregate metrics). - **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP. - **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly. ### Data Sources | Source | Method | Direction | Data | |--------|--------|-----------|------| | `services` table | Database query | Read | Node discovery (node_id, server_url, started) | | Node SSE | `GET {server_url}/v1/api/events/global` | Stream | Snapshot on connect, then real-time delta events (state, health, aggregate) | | Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation | | Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams | --- ## ClusterCollector The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition: 1. **Node discovery** — queries the `services` database table every 60 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners, and spawns/cancels SSE tasks for new/lost nodes. 2. **SSE manager** — a single asyncio event loop on one thread multiplexes persistent SSE connections to all discovered nodes via `GET /v1/api/events/global`. Each connection receives a `node_snapshot` on connect (workstreams, health, aggregate) followed by real-time delta events (`ws_state`, `ws_created`, `ws_closed`, `ws_rename`, `health_changed`, `aggregate`). On disconnect, the node is marked unreachable and the connection is retried with exponential backoff (1s–30s). An `?expected_node_id=` query parameter provides identity verification against IP reuse (server returns 409 on mismatch). A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect. ### Thread Safety All reads and writes to the node/workstream map are protected by a single `threading.Lock`. Query methods acquire the lock, copy data, and release before returning. ### Scale Considerations - **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory - **1,000 nodes** connected via persistent SSE — a single asyncio event loop multiplexes all connections with negligible overhead. Ensure `ulimit -n` >= 4096 for fd headroom - **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale - **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking - **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode --- ## HTTP API ### `GET /v1/api/cluster/overview` Cluster-wide state counts and aggregate metrics. ```json { "nodes": 847, "workstreams": 4219, "states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31}, "aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200}, "version_drift": true, "versions": ["0.3.0", "0.3.1"] } ``` `version_drift` is `true` when nodes report different versions. `versions` lists all unique version strings sorted alphabetically. ### `GET /v1/api/cluster/nodes?sort=activity&limit=100&offset=0` Paginated node list. Sort options: `activity` (default, by running+attention count), `tokens`, `name`. ```json { "nodes": [ { "node_id": "db-west-04", "server_url": "http://10.0.3.4:8080", "ws_total": 6, "ws_running": 4, "ws_thinking": 0, "ws_attention": 1, "ws_idle": 1, "ws_error": 0, "total_tokens": 48200, "started": 1709294400.0, "reachable": true, "health": {"status": "ok", "version": "0.3.0"}, "version": "0.3.0" } ], "total": 847 } ``` ### `GET /v1/api/cluster/workstreams?state=running&node=db-west-04&search=perf&page=1&per_page=50` Filtered, paginated workstream list. All query parameters are optional. `per_page` is capped at 200. ```json { "workstreams": [ { "id": "a1b2c3d4", "name": "perf-db-west", "state": "running", "node": "db-west-04", "title": "Query latency analysis", "tokens": 24100, "context_ratio": 0.18, "activity": "bash: EXPLAIN ANALYZE...", "activity_state": "tool", "tool_calls": 42 } ], "total": 1847, "page": 1, "per_page": 50, "pages": 37 } ``` ### `GET /v1/api/cluster/node/{node_id}` Single node detail with all its workstreams. ```json { "node_id": "db-west-04", "server_url": "http://10.0.3.4:8080", "health": {"status": "ok", "version": "0.2.0", "model": "kappa_20b_131k"}, "workstreams": [...], "aggregate": {"total_tokens": 48200, "total_tool_calls": 156} } ``` ### `GET /v1/api/cluster/snapshot` Full cluster state in a single response — all nodes with their workstreams plus overview aggregates. Built under a single lock for internal consistency. Used by the browser on initial load and SSE reconnect. ```json { "nodes": [ { "node_id": "db-west-04", "server_url": "http://10.0.3.4:8080", "max_ws": 10, "reachable": true, "version": "0.3.0", "health": {"status": "ok", "version": "0.3.0"}, "aggregate": {"total_tokens": 48200, "total_tool_calls": 156}, "workstreams": [ {"id": "a1b2c3d4", "name": "perf-db-west", "state": "running", ...} ] } ], "overview": { "nodes": 847, "workstreams": 4219, "states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31}, "aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200}, "version_drift": false, "versions": ["0.3.0"] }, "timestamp": 1709294400.0 } ``` ### `POST /v1/api/cluster/workstreams/new` Create a new workstream on a target node. The console proxies the creation request to the target node's HTTP API. Requires `write` scope. Request: ```json { "node_id": "db-west-04", "name": "perf-analysis", "model": "gpt-5" } ``` All fields are optional: - `node_id` — targeting mode: - **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and proxies the request to it. - **`"pool"`** — console picks a reachable node with available capacity using round-robin selection. - **specific node ID** — proxies the request to that node directly. - `name` — workstream display name. Auto-generated if omitted. - `model` — model alias from the target node's registry. Uses the node's default model if omitted. Response: ```json { "status": "ok", "correlation_id": "a1b2c3d4e5f6", "target_node": "db-west-04" } ``` The response confirms the workstream creation request was proxied to the target node. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created. ### `GET /v1/api/cluster/events` Server-Sent Events stream for real-time cluster updates. The first event is always a `snapshot` containing the full cluster state (same shape as `GET /v1/api/cluster/snapshot` with an added `type: "snapshot"` field), followed by incremental events: ``` data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"} data: {"type":"ws_created","ws_id":"e5f6","node_id":"api-east-01","name":"new-task"} data: {"type":"ws_closed","ws_id":"a1b2"} data: {"type":"node_joined","node_id":"db-west-05"} data: {"type":"node_lost","node_id":"db-west-03"} ``` Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should reconnect on error with exponential backoff. ### `GET /health` ```json { "status": "ok", "service": "turnstone-console", "nodes": 847, "workstreams": 4219, "version_drift": false, "versions": ["0.3.0"] } ``` ### Admin API User and token management endpoints. All admin endpoints require `approve` scope, except for the setup endpoint which is public. #### `POST /v1/api/auth/setup` Creates the first admin user when no users exist. Public endpoint (no auth required). Returns a JWT and sets a session cookie. Returns `409` if users already exist. See [Security: First-time setup](security.md#first-time-setup) for full details. #### `POST /v1/api/admin/users` Create a new user. ```json { "username": "alice", "password": "s3cret", "scopes": ["read", "write"] } ``` #### `GET /v1/api/admin/users` List all users. ```json { "users": [ {"user_id": "u_abc123", "username": "alice", "scopes": ["read", "write"], "created": "2026-03-01T12:00:00Z"} ] } ``` #### `DELETE /v1/api/admin/users/{user_id}` Delete a user and revoke all their tokens. #### `POST /v1/api/admin/users/{user_id}/tokens` Create an API token for the given user. Returns a `ts_`-prefixed token string that can be used for Bearer auth or passed to `client.login(token="ts_xxx")`. ```json { "name": "CI pipeline", "scopes": ["read", "write"] } ``` #### `GET /v1/api/admin/users/{user_id}/tokens` List active tokens for a user (token strings are not returned, only metadata). #### `DELETE /v1/api/admin/tokens/{token_id}` Revoke a specific API token. ### Channel links | Method | Path | Description | |--------|------|-------------| | GET | `/v1/api/admin/users/{user_id}/channels` | List channel links for a user | | POST | `/v1/api/admin/users/{user_id}/channels` | Link a channel account (channel_type, channel_user_id) | | DELETE | `/v1/api/admin/channels/{channel_type}/{channel_user_id}` | Unlink a channel account | These endpoints manage the `channel_users` table mappings that connect external platform identities (e.g. Discord user IDs) to turnstone users. See [Channel Integrations](channels.md) for details on the linking flow. #### `GET /v1/api/auth/status` Public endpoint for login UI state detection. Returns auth configuration, not current-user identity. ```json { "auth_enabled": true, "has_users": true, "setup_required": false } ``` ### Auth Scopes The auth system uses three scopes instead of the earlier read/full role model: | Scope | Grants | |-------|--------| | `read` | Read-only access: dashboards, workstream lists, SSE streams, health | | `write` | Send messages, create/close workstreams, approve tool calls | | `approve` | Admin operations: manage users and API tokens | Scopes are cumulative — a user with `approve` scope can also perform `write` and `read` operations. --- ## Reverse Proxy The console reverse-proxies each node's server UI at `/node/{node_id}/`. This allows users to interact with any node's workstreams through the console port alone — individual server ports do not need to be exposed to the office network. ### Proxy Routes | Route | Behavior | |-------|----------| | `GET /node/{node_id}/` | Fetches the server's `index.html`, rewrites static and shared asset paths, injects a console-return banner and an inline JS proxy shim | | `GET /node/{node_id}/static/{path}` | Proxies page-specific static files | | `GET /node/{node_id}/shared/{path}` | Proxies shared static files (`base.css`, `auth.js`, etc.) | | `GET /node/{node_id}/v1/api/{path}` | Proxies GET API requests; detects SSE endpoints and streams them | | `POST /node/{node_id}/v1/api/{path}` | Proxies POST API requests with body forwarding | | `GET /node/{node_id}/{path}` | Proxies non-API endpoints (health, metrics) | ### URL Rewriting The server UI uses root-relative URLs (`/v1/api/workstreams/{ws_id}/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `` tags cannot rewrite root-relative URLs, the console uses a JS shim approach: 1. **HTML rewriting** — when serving `index.html`, replaces `href=` and `src=` references to both `/static/` and `/shared/` with the proxy prefix (`/node/{node_id}/static/` and `/node/{node_id}/shared/` respectively). 2. **Inline JS shim** — injects an inline `