Add console workstream creation + server reverse proxy (#14)

* Add console workstream creation + server reverse proxy (#14)

Enable the console dashboard to create workstreams and proxy server UIs,
so users only need network access to the console port.

Workstream creation via MQ:
- POST /api/cluster/workstreams/new with three targeting modes:
  specific node (directed queue), auto (best node by capacity),
  or general pool (shared queue, any bridge picks up)
- Console pushes CreateWorkstreamMessage to Redis; bridge handles
  the rest (server creation, ownership registration, SSE events)

Reverse proxy for server UIs:
- /node/{node_id}/ serves the server's HTML with static path rewriting
  and a console-return banner injected after <body>
- JS proxy shim prepended to app.js overrides fetch() and EventSource()
  to route root-relative URLs through /node/{id}/api/...
- SSE streams proxied via httpx.AsyncClient(timeout=None) with per-
  connection clients for long-lived streams
- GET/POST API requests forwarded with body and auth token

Security:
- Proxy write paths checked against WRITE_PATHS to prevent read-token
  escalation (read tokens cannot POST /api/send through proxy)
- html.escape() on node_id in banner HTML to prevent XSS
- String length limits on name/model inputs

Frontend:
- "+ new" button in header opens creation modal with node dropdown
  (Auto / General pool / specific nodes with capacity display)
- Modal has focus trap, backdrop dismiss, scroll lock, keyboard handling
- Workstream rows and node links deep-link via proxy paths
- Custom select arrow, Instrument Panel modal styling

Documentation:
- docs/console.md rewritten with proxy and creation API docs
- docs/architecture.md console section updated
- PlantUML diagrams 01, 11, 12 updated + PNGs re-rendered
- README.md updated

28 new tests (741 total), ruff + mypy clean.

* Fix Copilot PR #14 review issues: auth bypass, XSS, proxy robustness

- Normalize trailing slashes in required_role() to prevent write-role
  bypass via /api/send/ or /node/{id}/api/send/ (auth.py)
- Validate node_id format in proxy handlers (alphanumeric, dot, dash,
  underscore only) to prevent injection vectors
- Use json.dumps() for JS proxy shim prefix to prevent script injection
- URL-quote node_id in HTML attribute contexts (proxy_index, proxy_static)
- Check upstream status in _proxy_sse() — emit error event on non-200
  instead of keeping a dead SSE connection open
- Check upstream status in proxy_index() — propagate non-2xx errors
- Forward query string in _proxy_post() (consistency with _proxy_get)
- Handle JSON null values in create_workstream() — treat null as empty,
  reject non-string types with 400
- Fix docs/diagram LPUSH → RPUSH to match actual broker implementation
This commit is contained in:
Patrick Buckley
2026-03-03 18:25:18 -08:00
committed by GitHub
parent f02972c11d
commit 6c5441435b
17 changed files with 1356 additions and 70 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
- **Cluster dashboard** — real-time view of all nodes, workstreams, and resource utilization
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
```
@@ -72,7 +72,7 @@ pip install turnstone[console]
turnstone-console --redis-host localhost --port 8090
```
Then open `http://localhost:8090` for the cluster-wide dashboard.
Then open `http://localhost:8090` for the cluster-wide dashboard. Create workstreams from the console and interact with any node's server UI through the built-in reverse proxy — no direct server port access required.
### Docker
+34 -19
View File
@@ -976,31 +976,46 @@ re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
### Cluster Console
```
Event subscriber Node discovery Poll loop
+------------------+ +------------------+ +-------------------+
| SUBSCRIBE on | | SCAN node:* keys | | For each node: |
| events:cluster | | every 15 seconds | | GET /api/dash |
| Apply state | | Add/remove nodes | | GET /health |
| changes to | | Emit join/lost | | ThreadPoolExecutor|
| in-memory model | | events | | (50 workers) |
+------------------+ +------------------+ +-------------------+
| | |
+-- Redis pub/sub +-- Redis SCAN +-- HTTP to each
(SUBSCRIBE) (every 15s) server (every 10s)
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
+------------------+ +----------------------------+
| Event subscriber | | POST /api/cluster/ |
| SUBSCRIBE on | | workstreams/new |
| events:cluster | | → LPUSH to Redis |
+------------------+ | inbound:{node_id} |
| Node discovery | +----------------------------+
| SCAN node:* keys | | GET /node/{node_id}/ |
| every 15 seconds | | → httpx.AsyncClient |
+------------------+ | proxy to server_url |
| Poll loop | | GET /node/{id}/api/events |
| GET /api/dash | | → SSE stream proxy |
| GET /health | | POST /node/{id}/api/send |
| ThreadPoolExec | | → forwarded to server |
+------------------+ +----------------------------+
```
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
endpoint uses `EventSourceResponse` with the same listener queue pattern as
the main server. `ClusterCollector`'s background threads (event subscriber,
node discovery, poll loop) remain unchanged — they use sync Redis clients
and `ThreadPoolExecutor` for parallel HTTP polling.
node discovery, poll loop) use sync Redis clients and `ThreadPoolExecutor`
for parallel HTTP polling.
The console is read-only — it never writes to Redis queues or sends commands to servers.
Real-time events provide instant state transitions; periodic polling provides full data
consistency (tokens, context ratios, activity strings). Clicking a workstream row in the
console opens the node's server UI with `?ws_id=<id>` for direct deep linking — the
server parses this on load and auto-selects the workstream. See [docs/console.md](console.md)
for the full API reference.
The console has two write-path capabilities:
1. **Workstream creation** — pushes `CreateWorkstreamMessage` to Redis inbound
queues targeting specific nodes. The bridge on each node picks up the message
and creates the workstream on the local server. Auto-selects the node with
the most available capacity if no target is specified.
2. **Reverse proxy** — serves each node's server UI through the console port at
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
A JS shim is injected into the server's `app.js` to override `fetch()` and
`EventSource()`, routing root-relative URLs through the proxy prefix. This
eliminates the need for direct network access to individual server nodes.
Clicking a workstream row in the console opens the proxied server UI at
`/node/{node_id}/?ws_id=<id>` — the server's JS parses this on load and
auto-selects the workstream. See [docs/console.md](console.md) for the full
API reference.
---
+109 -17
View File
@@ -1,28 +1,40 @@
# Cluster Dashboard (turnstone-console)
`turnstone-console` is a standalone monitoring service that provides cluster-wide visibility across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
The console is read-only — it observes but does not own workstreams or drive LLM sessions.
The console also supports **workstream creation** (dispatched via MQ 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)
```
turnstone-server ── turnstone-bridge ──→ Redis ──→ turnstone-console ──→ Browser
(per node) (per node) (shared) (one instance)
┌── Redis ←── turnstone-bridge ── turnstone-server
│ (MQ) (per node) (per node)
turnstone-console ──────┤
(one instance) │
└── turnstone-server (direct HTTP proxy)
Browser
```
Each bridge publishes state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes once to that channel for real-time updates and periodically polls each node's `GET /api/dashboard` for full workstream snapshots.
Data flows in two directions:
- **Inbound (monitoring):** Bridges publish state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes for real-time updates and periodically polls each node's `GET /api/dashboard` for full workstream snapshots.
- **Outbound (control):** The console pushes `CreateWorkstreamMessage` to Redis inbound queues targeting specific nodes. Bridges pick up these messages and create workstreams on their local servers.
- **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 | Frequency | Data |
| Source | Method | Direction | Data |
|--------|--------|-----------|------|
| Redis heartbeats | `SCAN turnstone:node:*` | Every 15s | Node discovery (node_id, server_url, started) |
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Real-time | State changes, creates, closes, renames |
| Node HTTP API | `GET {server_url}/api/dashboard` | Every 10s | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Every 10s | Node health status |
| Redis heartbeats | `SCAN turnstone:node:*` | Read | Node discovery (node_id, server_url, started) |
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Read | State changes, creates, closes, renames |
| Node HTTP API | `GET {server_url}/api/dashboard` | Read | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
| Redis inbound queue | `RPUSH turnstone:inbound:{node_id}` | Write | Workstream creation commands |
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
### Redis Key: Cluster Event Channel
@@ -129,6 +141,40 @@ Single node detail with all its workstreams.
}
```
### `POST /api/cluster/workstreams/new`
Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `"full"` auth role.
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 pushes to its directed queue.
- **`"pool"`** — pushes to the shared inbound queue; the next available bridge picks it up (true general-pool dispatch).
- **specific node ID** — pushes to that node's directed queue.
- `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"
}
```
Creation is asynchronous — the response confirms the MQ message was dispatched. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
### `GET /api/cluster/events`
Server-Sent Events stream for real-time cluster updates.
@@ -151,27 +197,72 @@ Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should
---
## 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 asset paths, injects a console-return banner and a JS proxy shim |
| `GET /node/{node_id}/static/{path}` | Proxies static files; injects a JS shim into `app.js` |
| `GET /node/{node_id}/api/{path}` | Proxies GET API requests; detects SSE endpoints and streams them |
| `POST /node/{node_id}/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 (`/api/send`, `/static/app.js`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
1. **HTML rewriting** — when serving `index.html`, replaces `href="/static/"` and `src="/static/"` with the proxy prefix (`/node/{node_id}/static/`).
2. **JS shim injection** — when serving `app.js`, prepends an IIFE that overrides `window.fetch()` and `window.EventSource()` to prepend the proxy prefix to any root-relative URL. This intercepts all API calls and SSE connections transparently.
3. **Console-return banner** — injects a thin inline-styled `<div>` after `<body>` with a "← Console" link and the node ID, providing navigation back to the dashboard.
### SSE Proxy
SSE streams (`/api/events`, `/api/events/global`) are proxied by creating a per-connection `httpx.AsyncClient(timeout=None)`, streaming the upstream response via `aiter_text()`, parsing SSE framing (`\n\n` delimiters), and re-emitting events through `EventSourceResponse`. Each proxied SSE stream requires its own httpx client since the shared client's 30-second timeout would kill long-lived connections.
### Authentication
The proxy forwards requests to server nodes using the console's `--auth-token`. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/api/send`, `/api/approve`, etc.) require the `"full"` auth role, preventing read-only tokens from escalating to write operations.
---
## Browser Dashboard
The web UI has three views, toggled client-side:
The web UI has four views, toggled client-side:
### 1. Cluster Overview (landing)
- **State cards** — 5 clickable cards (running, thinking, attention, idle, error) with count and colored top border. Clicking filters to that state.
- **Aggregate bar** — total tokens and tool calls across the cluster.
- **Node table** — columns: NODE, WS, RUN, ATTN, TOKENS, HEALTH. Sorted by activity. Clickable rows drill down to node detail.
- **"+ new" button** — opens the workstream creation modal (see below).
### 2. Node Drill-down
Breadcrumb: `Cluster > db-west-04`. Shows the node's workstreams in a table matching the per-node dashboard layout (STATE, NAME, NODE, TASK, TOKENS, CTX) with activity sub-lines. Includes a link to the node's own dashboard (`http://{server_url}/`).
Breadcrumb: `Cluster > db-west-04`. Shows the node's workstreams in a table matching the per-node dashboard layout (STATE, NAME, MODEL, NODE, TASK, TOKENS, CTX) with activity sub-lines. Includes a link to the node's proxied server UI.
**Deep linking:** Clicking a workstream row opens the node's server UI in a new tab with `?ws_id=<id>`, which auto-selects that workstream. A `↗` indicator appears on hover to signal the external navigation. Rows without a `server_url` are non-interactive.
**Proxy deep-linking:** Clicking a workstream row opens the node's server UI in a new tab via the proxy at `/node/{node_id}/?ws_id=<id>`, which auto-selects that workstream. Users do not need direct network access to the server node.
### 3. Filtered Workstreams
Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated workstream table. NODE column values are clickable to filter further. Pagination controls at bottom. Workstream rows are deep-linkable when `server_url` is available (injected by the collector from the parent node).
Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated workstream table. NODE column values are clickable to filter further. Pagination controls at bottom. Workstream rows use proxy deep-links.
All three views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
### 4. Workstream Creation Modal
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" pushes to the shared queue for any bridge to pick up, or a specific node from the list (showing capacity).
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
On submit, `POST /api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard.
All four views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
---
@@ -201,6 +292,7 @@ CLI flags for `turnstone-console`:
| `--redis-password` | `$REDIS_PASSWORD` | Redis password |
| `--redis-db` | `0` | Redis DB |
| `--poll-interval` | `10` | Node polling interval (seconds) |
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
| `--log-level` | `INFO` | Log level |
Config file (`~/.config/turnstone/config.toml`):
@@ -233,7 +325,7 @@ turnstone-server --port 8080
turnstone-bridge --server-url http://localhost:8080 --node-id node-a
# Start cluster console (one instance)
turnstone-console --redis-host localhost --port 8090
turnstone-console --redis-host localhost --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
```
Open `http://localhost:8090` for the cluster dashboard.
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8759150dc198833adcf3acfb0eaf4b13d2042228c36b480c1799496c4177e09b
size 154281
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
size 162310
+2 -2
View File
@@ -47,8 +47,8 @@ bridge --> server : HTTP REST\n(POST /api/send, etc.)
bridge <-- server : SSE\n(GET /api/events)
bridge --> redis : Redis LIST + PUBSUB\n+ STRING (routing, heartbeats)
console --> redis : Redis PUBSUB + STRING\n(cluster channel, heartbeats)
console --> server : HTTP polling\n(GET /api/dashboard)
console --> redis : Redis PUBSUB + STRING + LIST\n(cluster events, heartbeats,\nworkstream creation commands)
console --> server : HTTP polling + reverse proxy\n(GET /api/dashboard,\nproxy /node/{id}/* traffic)
sim --> redis : Redis LIST + PUBSUB\n+ STRING (heartbeats)
+98 -1
View File
@@ -1,6 +1,6 @@
@startuml
!theme plain
title Turnstone — Console Dashboard Data Collection
title Turnstone — Console Dashboard Data Flow
skinparam sequenceArrowThickness 1.5
@@ -8,6 +8,7 @@ participant "Browser" as Browser
participant "Console\nStarlette App" as Server
participant "ClusterCollector" as CC
collections "Redis" as Redis
participant "Node-A Bridge" as BridgeA
participant "Node-A\n(real server)" as NodeA
participant "Node-B\n(sim node)" as NodeB
@@ -121,4 +122,100 @@ Server -> CC : get_workstreams(state="running",\nnode="sim-0003")
CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1}
Server --> Browser : JSON response
== Workstream Creation (via MQ) ==
Browser -> Server : POST /api/cluster/workstreams/new\n{node_id:"nodeA", name:"new-task"}
activate Server #FFECB3
Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
CC --> Server : node validated
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task"}
Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
deactivate Server
note right of Redis
Bridge on Node-A picks up the
message from its directed queue,
POSTs to /api/workstreams/new,
registers ownership, publishes
ws_created to cluster channel.
end note
Redis --> BridgeA : BLPOP turnstone:inbound:nodeA
activate BridgeA
BridgeA -> NodeA : POST /api/workstreams/new\n{name:"new-task"}
NodeA --> BridgeA : {ws_id:"ws789", name:"new-task"}
BridgeA -> Redis : SET turnstone:ws:ws789 = nodeA
BridgeA -> Redis : PUBLISH turnstone:events:cluster\n{type:"ws_created", ws_id:"ws789",\nnode_id:"nodeA", name:"new-task"}
deactivate BridgeA
Redis --> CC : ws_created event
CC -> CC : Add workstream to\nNodeSnapshot["nodeA"]
CC -> CC : _fanout(event)
Server -> Browser : SSE: data: {"type":"ws_created",...}
== Reverse Proxy (server UI through console port) ==
Browser -> Server : GET /node/nodeA/
activate Server #FFF9C4
Server -> CC : get_node_detail("nodeA")\n→ server_url = "http://10.0.1.1:8080"
Server -> NodeA : GET http://10.0.1.1:8080/\n(via httpx.AsyncClient)
activate NodeA
NodeA --> Server : index.html
deactivate NodeA
Server -> Server : Rewrite static paths:\nhref="/static/" → "/node/nodeA/static/"\nInject console-return banner\nafter <body>
Server --> Browser : Rewritten HTML
deactivate Server
Browser -> Server : GET /node/nodeA/static/app.js
activate Server #FFF9C4
Server -> NodeA : GET http://10.0.1.1:8080/static/app.js
activate NodeA
NodeA --> Server : app.js
deactivate NodeA
Server -> Server : Prepend JS proxy shim:\nOverride fetch() and EventSource()\nto prepend "/node/nodeA" prefix
Server --> Browser : Shimmed app.js
deactivate Server
note right of Browser
All fetch("/api/send") calls in the
server UI now become fetch("/node/nodeA/api/send"),
routed through the console proxy.
end note
Browser -> Server : GET /node/nodeA/api/events?ws_id=ws789
activate Server #FFF9C4
Server -> NodeA : GET http://10.0.1.1:8080/api/events?ws_id=ws789\n(SSE stream via httpx.AsyncClient timeout=None)
activate NodeA
loop SSE streaming
NodeA --> Server : data: {"type":"content","text":"..."}\n\n
Server --> Browser : data: {"type":"content","text":"..."}\n\n
end
deactivate NodeA
deactivate Server
Browser -> Server : POST /node/nodeA/api/send\n{message:"hello", ws_id:"ws789"}
activate Server #FFF9C4
Server -> NodeA : POST http://10.0.1.1:8080/api/send\n(body forwarded)
activate NodeA
NodeA --> Server : {status:"ok"}
deactivate NodeA
Server --> Browser : {status:"ok"}
deactivate Server
@enduml
+2 -2
View File
@@ -87,8 +87,8 @@ bridge --> server : HTTP REST\n(POST /api/send, etc.)
bridge <-- server : SSE\n(GET /api/events)
bridge --> redis : Redis protocol\n(queues + pubsub)
console --> redis : Redis PUBSUB\n(cluster channel)
console --> server : HTTP polling\n(GET /api/dashboard)
console --> redis : Redis PUBSUB + LIST\n(cluster events,\nws creation commands)
console --> server : HTTP polling + proxy\n(GET /api/dashboard,\nproxy /node/{id}/*)
sim --> redis : Redis protocol\n(queues + pubsub + keys)
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:341a8ab1483b1e0146878bd384a11d56bc78d29262de8262d06ef924317e2762
size 139969
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
size 162310
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26e048235ad9b618b671ef732a23ba08c3b8972c5e401e4d9e4c53ee282221ca
size 239690
oid sha256:943b16854e468b5f1e6a856dd76efea6f97caa6ef002577938dc84ec447ce4f1
size 410922
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e6c1dfaef840d5228645aaad3637c973b2f71c372595814f3b743a991f5c6fc
size 239128
oid sha256:9aff8121430c2afd00f27f1e608b762e8fa8220ace1ceea4e919f2e28147e5e2
size 249519
+44
View File
@@ -309,6 +309,50 @@ class TestCheckRequest:
assert allowed is False
assert status == 403
def test_proxy_write_read_token_403(self, enabled):
"""Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_proxy_write_trailing_slash_read_token_403(self, enabled):
"""Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_direct_write_trailing_slash_read_token_403(self, enabled):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg = check_request(enabled, "POST", "/api/send/", "Bearer tok_read")
assert allowed is False
assert status == 403
def test_proxy_write_full_token_ok(self, enabled):
"""Full tokens pass through proxy write routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full"
)
assert allowed is True
def test_proxy_read_endpoint_read_token_ok(self, enabled):
"""Read tokens can access proxy read endpoints."""
allowed, status, msg = check_request(
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read"
)
assert allowed is True
def test_console_create_ws_read_token_403(self, enabled):
"""Read tokens cannot create workstreams."""
allowed, status, msg = check_request(
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_approve_full_token_ok(self, enabled):
allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_full")
assert allowed is True
+348
View File
@@ -640,3 +640,351 @@ class TestConsoleHTTPEndpoints:
def test_404(self, client):
resp = client.get("/nonexistent")
assert resp.status_code == 404
def test_index_has_new_ws_button(self, client):
status, body, ct = self._get_raw(client, "/")
assert status == 200
assert 'id="new-ws-btn"' in body
assert "showNewWsModal" in body
def test_index_has_new_ws_modal(self, client):
status, body, ct = self._get_raw(client, "/")
assert 'id="new-ws-overlay"' in body
assert 'id="new-ws-node"' in body
# ---------------------------------------------------------------------------
# Workstream creation tests
# ---------------------------------------------------------------------------
class TestConsoleWorkstreamCreation:
"""Tests for POST /api/cluster/workstreams/new."""
@pytest.fixture()
def mock_collector(self):
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 2,
"workstreams": 5,
"states": {"running": 1, "idle": 4, "thinking": 0, "attention": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"health": {},
"workstreams": [],
"aggregate": {},
"reachable": True,
}
collector.get_nodes.return_value = (
[
{"node_id": "node-a", "reachable": True, "max_ws": 10, "ws_total": 8},
{"node_id": "node-b", "reachable": True, "max_ws": 10, "ws_total": 3},
],
2,
)
return collector
@pytest.fixture()
def client_and_broker(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
mock_broker = MagicMock()
app = create_app(
collector=mock_collector,
broker=mock_broker,
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client, mock_broker
client.close()
def test_create_with_explicit_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "node-a", "name": "test-ws"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["target_node"] == "node-a"
assert "correlation_id" in data
broker.push_inbound.assert_called_once()
# Verify the pushed message
msg_json = broker.push_inbound.call_args[0][0]
msg = json.loads(msg_json)
assert msg["type"] == "create_workstream"
assert msg["target_node"] == "node-a"
assert msg["name"] == "test-ws"
def test_create_with_model(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "node-a", "model": "gpt-5"},
)
assert resp.status_code == 200
msg_json = broker.push_inbound.call_args[0][0]
msg = json.loads(msg_json)
assert msg["model"] == "gpt-5"
def test_create_auto_selects_best_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
json={"name": "auto-test"},
)
assert resp.status_code == 200
data = resp.json()
# node-b has more headroom (10-3=7 vs 10-8=2)
assert data["target_node"] == "node-b"
def test_create_no_reachable_nodes(self, client_and_broker, mock_collector):
client, broker = client_and_broker
mock_collector.get_nodes.return_value = ([], 0)
resp = client.post("/api/cluster/workstreams/new", json={})
assert resp.status_code == 503
assert "No reachable nodes" in resp.json()["error"]
def test_create_unknown_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
mock_collector.get_node_detail.return_value = None
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "nonexistent"},
)
assert resp.status_code == 404
def test_create_invalid_json(self, client_and_broker):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
content=b"not json",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
def test_create_pushes_to_directed_queue(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "node-a"},
)
assert resp.status_code == 200
# Verify push_inbound called with node_id kwarg
call_kwargs = broker.push_inbound.call_args
assert call_kwargs[1]["node_id"] == "node-a"
def test_create_pool_pushes_to_shared_queue(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "pool", "name": "pool-task"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["target_node"] == "pool"
broker.push_inbound.assert_called_once()
# Shared queue: no node_id kwarg (or empty)
call_args = broker.push_inbound.call_args
assert call_args[1].get("node_id", "") == ""
# Message should have no target_node
msg = json.loads(call_args[0][0])
assert msg["type"] == "create_workstream"
assert msg["target_node"] == ""
assert msg["name"] == "pool-task"
def test_create_pool_skips_node_validation(self, client_and_broker, mock_collector):
"""Pool mode doesn't need a valid node_id — it goes to the shared queue."""
client, broker = client_and_broker
mock_collector.get_node_detail.return_value = None # would 404 for directed
resp = client.post(
"/api/cluster/workstreams/new",
json={"node_id": "pool"},
)
assert resp.status_code == 200
assert resp.json()["target_node"] == "pool"
# ---------------------------------------------------------------------------
# Proxy tests
# ---------------------------------------------------------------------------
class TestConsoleProxy:
"""Tests for /node/{node_id}/ reverse proxy."""
@pytest.fixture()
def mock_collector(self):
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 1,
"workstreams": 2,
"states": {"running": 0, "idle": 2, "thinking": 0, "attention": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"health": {},
"workstreams": [],
"aggregate": {},
"reachable": True,
}
return collector
@pytest.fixture()
def client(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_proxy_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/")
assert resp.status_code == 404
def test_proxy_static_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/static/app.js")
assert resp.status_code == 404
def test_proxy_api_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/api/workstreams")
assert resp.status_code == 404
def test_proxy_api_post_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.post(
"/node/unknown/api/send",
json={"message": "hello", "ws_id": "ws1"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Proxy URL rewriting unit tests (no HTTP needed)
# ---------------------------------------------------------------------------
class TestProxyRewriting:
"""Test the JS shim and HTML rewriting logic."""
def test_js_shim_contains_prefix_placeholder(self):
from turnstone.console.server import _JS_PROXY_SHIM
assert "PREFIX_PLACEHOLDER" in _JS_PROXY_SHIM
replaced = _JS_PROXY_SHIM.replace("PREFIX_PLACEHOLDER", "/node/my-node")
assert "/node/my-node" in replaced
assert "PREFIX_PLACEHOLDER" not in replaced
def test_js_shim_overrides_fetch_and_eventsource(self):
from turnstone.console.server import _JS_PROXY_SHIM
assert "window.fetch" in _JS_PROXY_SHIM
assert "window.EventSource" in _JS_PROXY_SHIM
def test_console_banner_contains_placeholder(self):
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
assert "NODE_ID_PLACEHOLDER" in _CONSOLE_BANNER_TEMPLATE
assert "Console" in _CONSOLE_BANNER_TEMPLATE
def test_html_rewriting_changes_static_paths(self):
"""Simulate the proxy_index rewriting logic."""
sample_html = (
'<link rel="stylesheet" href="/static/style.css">\n'
'<script src="/static/app.js"></script>'
)
prefix = "/node/test-node"
rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/')
rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/')
assert "/node/test-node/static/style.css" in rewritten
assert "/node/test-node/static/app.js" in rewritten
# Originals should be gone
assert 'href="/static/' not in rewritten
assert 'src="/static/' not in rewritten
def test_banner_injection_after_body(self):
"""Simulate the banner injection logic."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
sample_html = "<html><body><div>content</div></body></html>"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "node-a")
result = sample_html.replace("<body>", "<body>" + banner, 1)
assert "node-a" in result
assert "Console" in result
assert result.startswith("<html><body><div")
# ---------------------------------------------------------------------------
# _pick_best_node unit tests
# ---------------------------------------------------------------------------
class TestPickBestNode:
"""Test the _pick_best_node helper."""
def test_picks_node_with_most_headroom(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[
{"node_id": "busy", "reachable": True, "max_ws": 10, "ws_total": 9},
{"node_id": "free", "reachable": True, "max_ws": 10, "ws_total": 2},
{"node_id": "mid", "reachable": True, "max_ws": 10, "ws_total": 5},
],
3,
)
assert _pick_best_node(collector) == "free"
def test_skips_unreachable_nodes(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[
{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0},
{"node_id": "up", "reachable": True, "max_ws": 10, "ws_total": 5},
],
2,
)
assert _pick_best_node(collector) == "up"
def test_returns_empty_when_no_nodes(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = ([], 0)
assert _pick_best_node(collector) == ""
def test_returns_empty_when_all_unreachable(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0}],
1,
)
assert _pick_best_node(collector) == ""
+360 -2
View File
@@ -2,6 +2,10 @@
Serves the cluster-level dashboard UI and provides REST/SSE APIs
backed by the ClusterCollector. Uses Starlette/ASGI with uvicorn.
Also provides:
- Workstream creation via MQ dispatch to target nodes
- Reverse proxy for server UIs so users only need console port access
"""
from __future__ import annotations
@@ -9,16 +13,20 @@ from __future__ import annotations
import argparse
import asyncio
import functools
import html
import json
import logging
import math
import os
import queue
import re
import textwrap
import urllib.parse
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
import httpx
from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
@@ -109,7 +117,78 @@ class AuthMiddleware:
# ---------------------------------------------------------------------------
# Route handlers
# Proxy helpers
# ---------------------------------------------------------------------------
# JS shim prepended to the server's app.js when proxied through the console.
# Overrides fetch() and EventSource() so root-relative URLs (/api/send etc.)
# route through the console proxy at /node/{node_id}/api/... instead.
_JS_PROXY_SHIM = """\
(function(){
var _pfx="PREFIX_PLACEHOLDER";
var _oF=window.fetch;
window.fetch=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return _oF.call(this,u,o);
};
var _oE=window.EventSource;
window.EventSource=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return new _oE(u,o);
};
window.EventSource.prototype=_oE.prototype;
window.EventSource.CONNECTING=_oE.CONNECTING;
window.EventSource.OPEN=_oE.OPEN;
window.EventSource.CLOSED=_oE.CLOSED;
})();
"""
_CONSOLE_BANNER_TEMPLATE = (
'<div style="background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);'
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
'display:flex;align-items:center;gap:12px;position:relative;z-index:9999">'
'<a href="/" style="color:#e5a042;text-decoration:none;font-weight:600;'
'padding:2px 0" '
"onmouseover=\"this.style.textDecoration='underline'\" "
"onmouseout=\"this.style.textDecoration='none'\">"
"&larr; Console</a>"
'<span style="color:#8a93ad;font-size:11px">NODE_ID_PLACEHOLDER</span>'
"</div>"
)
_VALID_NODE_ID = re.compile(r"^[a-zA-Z0-9._-]+$")
def _get_server_url(request: Request, node_id: str) -> str | None:
"""Resolve node_id to its server_url via the collector."""
if not node_id or not _VALID_NODE_ID.match(node_id) or len(node_id) > 256:
return None
collector: ClusterCollector = request.app.state.collector
detail = collector.get_node_detail(node_id)
if detail and detail.get("server_url"):
url: str = detail["server_url"]
return url.rstrip("/")
return None
def _pick_best_node(collector: ClusterCollector) -> str:
"""Select the reachable node with the most available capacity."""
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
best_id = ""
best_headroom = -1
for n in nodes:
if not n.get("reachable", False):
continue
headroom = n.get("max_ws", 10) - n.get("ws_total", 0)
if headroom > best_headroom:
best_headroom = headroom
best_id = n["node_id"]
return best_id
# ---------------------------------------------------------------------------
# Route handlers — dashboard
# ---------------------------------------------------------------------------
@@ -234,6 +313,265 @@ async def auth_logout(request: Request) -> Response:
return response
# ---------------------------------------------------------------------------
# Route handlers — workstream creation
# ---------------------------------------------------------------------------
async def create_workstream(request: Request) -> JSONResponse:
"""POST /api/cluster/workstreams/new — create a workstream via MQ.
Three targeting modes:
- ``node_id`` set to a specific node ID → directed to that node's queue
- ``node_id`` omitted or ``"auto"`` → console picks the node with most headroom
- ``node_id`` set to ``"pool"`` → pushed to the shared queue for any bridge
"""
try:
body: dict[str, Any] = await request.json()
except (ValueError, json.JSONDecodeError):
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
broker: RedisBroker = request.app.state.broker
collector: ClusterCollector = request.app.state.collector
raw_node_id = body.get("node_id", "")
raw_name = body.get("name", "")
raw_model = body.get("model", "")
if not isinstance(raw_node_id, str):
raw_node_id = "" if raw_node_id is None else None
if not isinstance(raw_name, str):
raw_name = "" if raw_name is None else None
if not isinstance(raw_model, str):
raw_model = "" if raw_model is None else None
if raw_node_id is None or raw_name is None or raw_model is None:
return JSONResponse({"error": "node_id, name, and model must be strings"}, status_code=400)
node_id = raw_node_id
name = raw_name[:256]
model = raw_model[:128]
from turnstone.mq.protocol import CreateWorkstreamMessage
# General pool — push to shared queue, any bridge picks it up
if node_id == "pool":
msg = CreateWorkstreamMessage(name=name, model=model)
broker.push_inbound(msg.to_json())
log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name)
return JSONResponse(
{
"status": "ok",
"correlation_id": msg.correlation_id,
"target_node": "pool",
}
)
# Auto-select node by most available capacity
if not node_id or node_id == "auto":
node_id = _pick_best_node(collector)
if not node_id:
return JSONResponse({"error": "No reachable nodes available"}, status_code=503)
# Validate node exists
detail = collector.get_node_detail(node_id)
if not detail:
return JSONResponse({"error": "Node not found"}, status_code=404)
msg = CreateWorkstreamMessage(
name=name,
model=model,
target_node=node_id,
)
broker.push_inbound(msg.to_json(), node_id=node_id)
return JSONResponse(
{
"status": "ok",
"correlation_id": msg.correlation_id,
"target_node": node_id,
}
)
# ---------------------------------------------------------------------------
# Route handlers — reverse proxy
# ---------------------------------------------------------------------------
async def proxy_index(request: Request) -> Response:
"""GET /node/{node_id}/ — serve proxied server UI with URL rewriting."""
node_id = request.path_params["node_id"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
safe_node = urllib.parse.quote(node_id, safe="")
prefix = f"/node/{safe_node}"
try:
resp = await client.get(f"{server_url}/")
if resp.status_code < 200 or resp.status_code >= 300:
log.debug("Upstream %s returned status %s", node_id, resp.status_code)
return JSONResponse(
{"error": "Upstream server error", "status_code": resp.status_code},
status_code=resp.status_code,
)
page = resp.text
# Rewrite static asset paths
page = page.replace('href="/static/', f'href="{prefix}/static/')
page = page.replace('src="/static/', f'src="{prefix}/static/')
# Inject console-return banner after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id))
page = page.replace("<body>", "<body>" + banner, 1)
return HTMLResponse(page)
except httpx.HTTPError as exc:
log.debug("Proxy index error for %s: %s", node_id, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_static(request: Request) -> Response:
"""GET /node/{node_id}/static/{path} — proxy static files."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
safe_node = urllib.parse.quote(node_id, safe="")
prefix = f"/node/{safe_node}"
try:
resp = await client.get(f"{server_url}/static/{path}")
content_type = resp.headers.get("content-type", "application/octet-stream")
body = resp.content
# Inject proxy shim into app.js
if path == "app.js":
shim = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
body = shim.encode("utf-8") + body
content_type = "application/javascript; charset=utf-8"
return Response(content=body, status_code=resp.status_code, media_type=content_type)
except httpx.HTTPError as exc:
log.debug("Proxy static error for %s/%s: %s", node_id, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_api(request: Request) -> Response:
"""Proxy API requests to target node. Detects SSE vs regular."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
# SSE detection: GET requests to events endpoints
if request.method == "GET" and path in ("events", "events/global"):
return await _proxy_sse(request, server_url, path)
if request.method == "POST":
return await _proxy_post(request, server_url, path)
return await _proxy_get(request, server_url, f"api/{path}")
async def proxy_non_api(request: Request) -> Response:
"""Proxy non-API GET endpoints (health, metrics) to target node."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
return await _proxy_get(request, server_url, path)
async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
"""Forward a GET request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
target = f"{server_url}/{path}"
if request.url.query:
target += f"?{request.url.query}"
try:
resp = await client.get(target)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy GET error for %s: %s", target, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def _proxy_post(request: Request, server_url: str, path: str) -> Response:
"""Forward a POST request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
body = await request.body()
content_type = request.headers.get("content-type", "application/json")
target = f"{server_url}/api/{path}"
if request.url.query:
target += f"?{request.url.query}"
try:
resp = await client.post(
target,
content=body,
headers={"Content-Type": content_type},
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy POST error for api/%s: %s", path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def _proxy_sse(request: Request, server_url: str, path: str) -> Response:
"""Proxy an SSE stream from the target server to the browser."""
target = f"{server_url}/api/{path}"
if request.url.query:
target += f"?{request.url.query}"
proxy_token: str = request.app.state.proxy_auth_token
async def sse_generator() -> AsyncGenerator[dict[str, str], None]:
headers: dict[str, str] = {}
if proxy_token:
headers["Authorization"] = f"Bearer {proxy_token}"
async with httpx.AsyncClient(timeout=None, headers=headers) as sse_client:
try:
async with sse_client.stream("GET", target) as resp:
if resp.status_code != 200:
log.debug(
"SSE proxy received status %s from %s",
resp.status_code,
target,
)
yield {
"event": "error",
"data": f"Upstream returned status {resp.status_code}",
}
return
buf = ""
async for chunk in resp.aiter_text():
if await request.is_disconnected():
return
buf += chunk
while "\n\n" in buf:
event_text, buf = buf.split("\n\n", 1)
data_lines = []
for line in event_text.split("\n"):
if line.startswith("data:"):
# SSE spec: strip exactly one leading space
value = line[5:]
if value.startswith(" "):
value = value[1:]
data_lines.append(value)
if data_lines:
yield {"data": "\n".join(data_lines)}
except httpx.HTTPError:
log.debug("SSE proxy stream ended for %s", target)
return EventSourceResponse(sse_generator(), ping=5)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@@ -241,8 +579,15 @@ async def auth_logout(request: Request) -> Response:
@asynccontextmanager
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# Create async HTTP client for proxy routes
headers: dict[str, str] = {}
token = app.state.proxy_auth_token
if token:
headers["Authorization"] = f"Bearer {token}"
app.state.proxy_client = httpx.AsyncClient(timeout=30, headers=headers)
yield
# Shutdown
await app.state.proxy_client.aclose()
app.state.collector.stop()
app.state.broker.close()
@@ -257,6 +602,7 @@ def create_app(
collector: ClusterCollector,
broker: RedisBroker,
auth_config: Any,
proxy_auth_token: str = "",
) -> Starlette:
"""Build the Starlette ASGI application for the console dashboard."""
app = Starlette(
@@ -265,12 +611,18 @@ def create_app(
Route("/api/cluster/overview", cluster_overview),
Route("/api/cluster/nodes", cluster_nodes),
Route("/api/cluster/workstreams", cluster_workstreams),
Route("/api/cluster/workstreams/new", create_workstream, methods=["POST"]),
Route("/api/cluster/node/{node_id}", cluster_node_detail),
Route("/api/cluster/events", cluster_events_sse),
Route("/health", health),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
# Proxy routes — serve server UI through console port
Route("/node/{node_id}/", proxy_index),
Route("/node/{node_id}/static/{path:path}", proxy_static),
Route("/node/{node_id}/api/{path:path}", proxy_api, methods=["GET", "POST"]),
Route("/node/{node_id}/{path:path}", proxy_non_api),
],
middleware=[
Middleware(
@@ -286,6 +638,7 @@ def create_app(
app.state.collector = collector
app.state.broker = broker
app.state.auth_config = auth_config
app.state.proxy_auth_token = proxy_auth_token
return app
@@ -386,7 +739,12 @@ def main() -> None:
auth_config = load_auth_config()
app = create_app(collector=collector, broker=broker, auth_config=auth_config)
app = create_app(
collector=collector,
broker=broker,
auth_config=auth_config,
proxy_auth_token=args.auth_token,
)
print(f"turnstone console running on http://{args.host}:{args.port}")
if auth_config.enabled:
+170 -15
View File
@@ -666,13 +666,9 @@ function drillDownToNode(nodeId, serverUrl) {
document.getElementById("breadcrumb").style.display = "";
document.getElementById("breadcrumb-label").textContent = nodeId;
var link = document.getElementById("node-link");
if (serverUrl) {
link.href = serverUrl;
link.style.display = "";
} else {
link.removeAttribute("href");
link.style.display = "none";
}
// Use proxy path so users don't need direct server access
link.href = "/node/" + encodeURIComponent(nodeId) + "/";
link.style.display = "";
document.getElementById("main").scrollTop = 0;
document.getElementById("node-ws-table").innerHTML =
'<div class="dashboard-empty">Loading workstreams...</div>';
@@ -906,15 +902,20 @@ function renderWsTable(container, wsList) {
sub.textContent = ws.activity || "";
row.appendChild(sub);
// Deep link: click opens server UI at this workstream
var wsServerUrl = ws.server_url || currentServerUrl;
if (wsServerUrl) {
// Deep link: click opens proxied server UI at this workstream
var wsNodeId = ws.node;
if (wsNodeId) {
row.classList.add("has-link");
(function (url, wsId) {
(function (nodeId, wsId) {
row.onclick = function () {
var target = new URL(url);
target.searchParams.set("ws_id", wsId);
window.open(target.href, "_blank", "noopener");
window.open(
"/node/" +
encodeURIComponent(nodeId) +
"/?ws_id=" +
encodeURIComponent(wsId),
"_blank",
"noopener",
);
};
row.onkeydown = function (e) {
if (e.key === "Enter" || e.key === " ") {
@@ -922,7 +923,7 @@ function renderWsTable(container, wsList) {
row.onclick();
}
};
})(wsServerUrl, ws.id);
})(wsNodeId, ws.id);
} else {
row.removeAttribute("role");
row.removeAttribute("tabindex");
@@ -1155,6 +1156,160 @@ document.addEventListener("keydown", function (e) {
}
});
// --- New Workstream Modal ---
var _newWsTrapHandler = null;
function showNewWsModal() {
// Don't open if login overlay is active
var login = document.getElementById("login-overlay");
if (login && login.style.display !== "none") return;
var overlay = document.getElementById("new-ws-overlay");
overlay.style.display = "flex";
document.body.style.overflow = "hidden";
// Backdrop click to dismiss
overlay.onclick = function (e) {
if (e.target === overlay) hideNewWsModal();
};
var select = document.getElementById("new-ws-node");
select.innerHTML =
'<option value="">Auto (best node by capacity)</option>' +
'<option value="pool">General pool (next available)</option>';
authFetch("/api/cluster/nodes?sort=activity&limit=100")
.then(function (r) {
return r.json();
})
.then(function (data) {
(data.nodes || []).forEach(function (n) {
if (!n.reachable) return;
var opt = document.createElement("option");
opt.value = n.node_id;
opt.textContent =
n.node_id +
" (" +
(n.ws_total || 0) +
"/" +
(n.max_ws || 10) +
" ws)";
select.appendChild(opt);
});
})
.catch(function () {
/* ignore — auto is always available */
});
document.getElementById("new-ws-name").value = "";
document.getElementById("new-ws-model").value = "";
var errEl = document.getElementById("new-ws-error");
errEl.style.display = "none";
errEl.textContent = "";
var btn = document.getElementById("new-ws-submit");
btn.disabled = false;
btn.textContent = "Create";
// Focus trap (same pattern as login overlay)
if (_newWsTrapHandler)
document.removeEventListener("keydown", _newWsTrapHandler);
_newWsTrapHandler = function (e) {
if (e.key === "Tab") {
var box = document.getElementById("new-ws-box");
var focusable = box.querySelectorAll("select, input, button");
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener("keydown", _newWsTrapHandler);
setTimeout(function () {
document.getElementById("new-ws-name").focus();
}, 50);
}
function hideNewWsModal() {
document.getElementById("new-ws-overlay").style.display = "none";
document.body.style.overflow = "";
if (_newWsTrapHandler) {
document.removeEventListener("keydown", _newWsTrapHandler);
_newWsTrapHandler = null;
}
var triggerBtn = document.getElementById("new-ws-btn");
if (triggerBtn) triggerBtn.focus();
}
function submitNewWs() {
var nodeId = document.getElementById("new-ws-node").value;
var name = document.getElementById("new-ws-name").value.trim();
var model = document.getElementById("new-ws-model").value.trim();
var errEl = document.getElementById("new-ws-error");
var btn = document.getElementById("new-ws-submit");
btn.disabled = true;
btn.textContent = "Creating\u2026";
errEl.style.display = "none";
var body = {};
if (nodeId) body.node_id = nodeId;
if (name) body.name = name;
if (model) body.model = model;
authFetch("/api/cluster/workstreams/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(function (r) {
return r.json();
})
.then(function (data) {
btn.disabled = false;
btn.textContent = "Create";
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = "block";
return;
}
hideNewWsModal();
var label =
data.target_node === "pool"
? "general pool"
: data.target_node || "auto";
showToast("Workstream created on " + label);
})
.catch(function () {
btn.disabled = false;
btn.textContent = "Create";
errEl.textContent = "Request failed";
errEl.style.display = "block";
});
}
// Escape closes the new-ws modal; Enter submits
document.addEventListener("keydown", function (e) {
var overlay = document.getElementById("new-ws-overlay");
if (!overlay || overlay.style.display === "none") return;
if (e.key === "Escape") {
e.preventDefault();
hideNewWsModal();
}
if (e.key === "Enter" && e.target.tagName !== "SELECT") {
e.preventDefault();
var btn = document.getElementById("new-ws-submit");
if (btn && !btn.disabled) submitNewWs();
}
});
// --- Init ---
history.replaceState({ view: "overview" }, "");
initLogin();
+22 -1
View File
@@ -14,6 +14,7 @@
<h1>turnstone <span class="header-dim">console</span></h1>
<span id="cluster-summary" aria-live="polite"></span>
<span id="status-bar" role="status" aria-live="polite"></span>
<button id="new-ws-btn" class="header-btn header-btn-accent" onclick="showNewWsModal()" title="Create workstream">+ new</button>
<button id="logout-btn" class="header-btn" onclick="logout()" style="display:none">logout</button>
<button id="theme-toggle" class="header-btn" onclick="toggleTheme()" aria-label="Toggle light/dark theme">&#9790;</button>
</div>
@@ -49,7 +50,7 @@
<span class="dash-col dash-col-ctx">CTX</span>
</div>
<div id="node-ws-table" class="dash-table" role="group" aria-label="Workstreams" aria-live="polite"></div>
<a id="node-link" class="node-link" target="_blank" rel="noopener">Open node dashboard</a>
<a id="node-link" class="node-link" target="_blank" rel="noopener">Open node UI</a>
</div>
<!-- FILTERED WORKSTREAMS -->
@@ -79,6 +80,26 @@
</div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="new-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="new-ws-title">
<div id="new-ws-box">
<h2 id="new-ws-title">New Workstream</h2>
<div id="new-ws-error" role="alert" aria-live="assertive"></div>
<label for="new-ws-node">Node</label>
<select id="new-ws-node">
<option value="">Auto (best available)</option>
</select>
<label for="new-ws-name">Name <span class="label-hint">optional</span></label>
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
<label for="new-ws-model">Model <span class="label-hint">optional</span></label>
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
<div id="new-ws-buttons">
<button id="new-ws-cancel" onclick="hideNewWsModal()">Cancel</button>
<button id="new-ws-submit" onclick="submitNewWs()">Create</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+145
View File
@@ -936,3 +936,148 @@ body {
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
/* ==========================================================================
Header accent button (+ new)
========================================================================== */
#header .header-btn-accent {
color: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
#header .header-btn-accent:hover {
background: var(--accent-dim);
color: var(--accent);
}
#header .header-btn-accent:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ==========================================================================
New Workstream Modal
========================================================================== */
#new-ws-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
#new-ws-box {
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 32px;
width: 380px;
max-width: 90vw;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.03),
0 24px 48px -12px rgba(0, 0, 0, 0.5),
0 0 80px -20px var(--accent-dim);
position: relative;
}
#new-ws-box::before {
content: '';
position: absolute;
top: -1px;
left: 20%;
right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
border-radius: 1px;
}
#new-ws-box h2 {
font-family: var(--font-display);
color: var(--accent);
font-size: 15px;
font-weight: 700;
margin-bottom: 18px;
letter-spacing: 0.02em;
}
#new-ws-box label {
display: block;
font-family: var(--font-display);
font-size: 10px;
font-weight: 600;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 14px;
margin-bottom: 5px;
}
.label-hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.6;
}
#new-ws-box select,
#new-ws-box input[type="text"] {
width: 100%;
padding: 9px 12px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--fg);
font: inherit;
font-size: 13px;
transition: border-color 0.15s, box-shadow 0.15s;
}
#new-ws-box select:focus,
#new-ws-box input:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-dim);
}
#new-ws-box input::placeholder { color: var(--fg-dim); opacity: 0.6; }
#new-ws-box select {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a93ad' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
#new-ws-error {
display: none;
color: var(--red);
font-size: 12px;
margin-bottom: 6px;
}
#new-ws-buttons {
display: flex;
gap: 10px;
margin-top: 22px;
justify-content: flex-end;
}
#new-ws-buttons button {
padding: 9px 20px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border-strong);
background: var(--bg-highlight);
color: var(--fg);
transition: background 0.15s, border-color 0.15s, color 0.15s;
font-family: var(--font-display);
font-weight: 500;
letter-spacing: 0.02em;
}
#new-ws-cancel:hover { background: var(--bg-elevated); border-color: var(--border-strong); }
#new-ws-submit {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 600;
}
#new-ws-submit:hover { filter: brightness(1.1); }
#new-ws-submit:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
#new-ws-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
@media (max-width: 380px) { #new-ws-box { padding: 24px 18px; } }
+12 -1
View File
@@ -39,6 +39,7 @@ WRITE_PATHS: frozenset[str] = frozenset(
"/api/command",
"/api/workstreams/new",
"/api/workstreams/close",
"/api/cluster/workstreams/new",
}
)
@@ -130,9 +131,19 @@ def required_role(method: str, path: str) -> str:
"""Return the minimum role needed for *method* + *path*.
Returns ``"full"`` for state-modifying POST endpoints, ``"read"`` otherwise.
Handles console proxy routes (``/node/{id}/api/...``) by extracting the
proxied path and checking it against ``WRITE_PATHS``.
"""
if method == "POST" and path in WRITE_PATHS:
normalized = path.rstrip("/") if path != "/" else path
if method == "POST" and normalized in WRITE_PATHS:
return "full"
# Console proxy routes: /node/{node_id}/api/{tail}
if method == "POST" and normalized.startswith("/node/"):
parts = normalized.split("/", 4) # ['', 'node', '{id}', 'api', '{tail}']
if len(parts) >= 5 and parts[3] == "api":
proxied_path = "/api/" + parts[4]
if proxied_path in WRITE_PATHS:
return "full"
return "read"