mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8759150dc198833adcf3acfb0eaf4b13d2042228c36b480c1799496c4177e09b
|
||||
size 154281
|
||||
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
|
||||
size 162310
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:341a8ab1483b1e0146878bd384a11d56bc78d29262de8262d06ef924317e2762
|
||||
size 139969
|
||||
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
|
||||
size 162310
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:26e048235ad9b618b671ef732a23ba08c3b8972c5e401e4d9e4c53ee282221ca
|
||||
size 239690
|
||||
oid sha256:943b16854e468b5f1e6a856dd76efea6f97caa6ef002577938dc84ec447ce4f1
|
||||
size 410922
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e6c1dfaef840d5228645aaad3637c973b2f71c372595814f3b743a991f5c6fc
|
||||
size 239128
|
||||
oid sha256:9aff8121430c2afd00f27f1e608b762e8fa8220ace1ceea4e919f2e28147e5e2
|
||||
size 249519
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'\">"
|
||||
"← 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
@@ -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();
|
||||
|
||||
@@ -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">☾</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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user