Skip to content

Commit 49d1a60

Browse files
allan sargeantclaude
andcommitted
Add runtime add/remove for sources/outputs, with web UI menus
Sources and outputs are no longer fixed at startup by the TOML config — crates/router/src/management.rs adds POST/DELETE /api/manage/sources and /outputs, and the web UI gets Add source / Add destination forms plus a remove control on every row/column. Config-loaded and API-added entries go through the identical code path (crates/router/src/registry.rs), so a config-declared source is exactly as manageable as one added later. Removal is real, not just bookkeeping: srt-io's spawn_input/spawn_output now return a tokio_util::sync::CancellationToken, threaded through relay_in/relay_out's select! loops so a removed input/output actually stops promptly and frees its socket — verified with lsof (add cam3, confirm the port binds, remove it, confirm the port frees), and again live in a real browser (found and fixed a real bug along the way: the add-source form's <input name="id"> collided with the DOM's own form.id property, a genuine HTML forms quirk — renamed to entry_id). crosspoint-core gained deregister_source/deregister_output and has_source/has_output to support this, with a new unit test. The add-source/add-destination transport dropdown also lists NDI, Scaler, and Media player as disabled options (per user request) — honest placeholders pointing at docs/roadmap.md rather than silently accepting a request that would fail, since only SRT is wired into management.rs today. Docs updated throughout (README, architecture.md, roadmap.md) to describe the runtime API and NDI's status, plus a new diagram (docs/diagrams/multi-transport-envelope.svg) illustrating how NDI's envelope encoding lets it share crosspoint-core's plain Bytes channel with SRT's direct relay. Updated README screenshot shows the new UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent af4e458 commit 49d1a60

16 files changed

Lines changed: 904 additions & 87 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
> **AI-assisted project.** This codebase was created with [Claude](https://claude.com/claude-code)
44
> (Anthropic), directed and reviewed by a human author. The relay/crosspoint
55
> engine and web UI have been exercised locally — including integration
6-
> tests that relay real SRT protocol traffic end-to-end and a live
6+
> tests that relay real SRT and NDI protocol traffic end-to-end and a live
77
> crosspoint switch via the web UI, see [Status](#status) — but **not yet
8-
> run against real-world third-party SRT encoders/decoders or over an
8+
> run against real-world third-party SRT/NDI encoders/decoders or over an
99
> actual (non-loopback) network path**. Review before relying on it for
1010
> anything live.
1111
@@ -17,11 +17,11 @@ SDI/HDMI.
1717

1818
![srt-router architecture: SRT inputs feeding crosspoint-core's per-source broadcast channels, a routing table mapping each output to one source, out to SRT outputs](docs/diagrams/architecture.svg)
1919

20-
<img src="docs/screenshots/crosspoint-grid.png" alt="The crosspoint web UI: a grid of outputs (program, preview) by sources (remote-feed, cam1, cam2), with the currently-routed cell highlighted green in each row" width="480">
20+
<img src="docs/screenshots/crosspoint-grid.png" alt="The crosspoint web UI: a grid of outputs (program, preview) by sources (cam1, cam2, remote-feed), each labeled with its transport kind and a remove control, plus Add source / Add destination buttons below" width="560">
2121

2222
*The web UI above is a real screenshot of the router running locally against
2323
[config/example.toml](config/example.toml), not a mockup — captured while
24-
verifying the routing/persistence/websocket behavior described in
24+
verifying the routing/persistence/websocket/add-remove behavior described in
2525
[Status](#status) below.*
2626

2727
## What it does
@@ -39,14 +39,24 @@ crosspoint as a "source" without the engine caring that they're not relayed
3939
SRT. **Not built yet** — Phase 1 is relay-only; see
4040
[docs/roadmap.md](docs/roadmap.md).
4141

42-
Control is a local web UI (a crosspoint grid: click a cell to route that
43-
output from that source) backed by a small REST API. No auth/TLS — this is
44-
meant to run on a trusted operations network, the same trust model as a
45-
hardware router's control port.
42+
Control is a local web UI backed by a small REST API: a crosspoint grid
43+
(click a cell to route that output from that source), plus **Add
44+
source**/**Add destination** menus and a remove control on every row/column
45+
for adding or tearing down SRT inputs/outputs at runtime — not just what was
46+
in the TOML config at startup. No auth/TLS — this is meant to run on a
47+
trusted operations network, the same trust model as a hardware router's
48+
control port.
49+
50+
**Transports beyond SRT:** `crates/ndi-io` is a real, tested NDI transport
51+
(see [Status](#status)) but isn't wired into the router binary/web UI yet —
52+
the add-source/add-destination menus show NDI as a disabled option for now.
53+
`crates/omt-io` is a placeholder for the equivalent OMT transport (open,
54+
MIT-licensed) — not yet implemented, see [docs/roadmap.md](docs/roadmap.md).
4655

4756
## Status
4857

49-
**Phase 1 (current): relay-only crosspoint + web UI.** Working:
58+
**Phase 1 (current): relay-only crosspoint + web UI, dynamic add/remove.**
59+
Working:
5060

5161
- SRT input/output as either `listener` (this router waits for a
5262
connection) or `caller` (this router dials out), each reconnecting on its
@@ -56,27 +66,46 @@ hardware router's control port.
5666
- A local web UI (`crates/web`) — grid of outputs x sources, click to route,
5767
updated live over a websocket (`GET /ws`) with a REST poll (`GET
5868
/api/state`) as first paint / fallback.
69+
- **Runtime add/remove**: `POST`/`DELETE /api/manage/sources` and
70+
`/api/manage/outputs` (`crates/router/src/management.rs`) spawn or tear
71+
down an SRT input/output on the fly — the same code path the static TOML
72+
config uses at startup, so a config-declared source is exactly as
73+
removable as one added later. Backed by
74+
[`tokio_util::sync::CancellationToken`](https://docs.rs/tokio-util) per
75+
task (added to `srt-io`) so removal actually stops the task and frees the
76+
socket, not just forgets about it. The web UI exposes this as **Add
77+
source**/**Add destination** forms plus a remove control per row/column.
5978
- Routing changes optionally persist to disk (`[state]` in the config) and
6079
reload on restart, overriding each output's `default_source`.
80+
- `crates/ndi-io`: a real NDI transport using
81+
[grafton-ndi](https://github.com/GrantSparks/grafton-ndi) (Apache-2.0)
82+
against the actual NDI SDK — not wired into the router binary yet (see
83+
[What it does](#what-it-does)), but its own integration test drives a real
84+
NDI sender and receiver against it, consistently passing.
6185
- CI (GitHub Actions) runs `fmt --check`, `clippy -D warnings`, and the full
62-
test suite on every push/PR.
86+
test suite on every push/PR — SRT-only (`ndi-io`/`omt-io` need real
87+
SDKs CI can't install, so they're real workspace members but excluded
88+
from `default-members`, see [docs/architecture.md](docs/architecture.md)).
6389
- Verified locally, not just compiled: `cargo test` passes, including
64-
integration tests (`crates/srt-io/tests/relay.rs`) that relay real SRT
65-
protocol traffic end-to-end through the crosspoint using `srt-tokio`
66-
clients as the encoder/decoder — one test also exercises a **live
67-
re-route mid-stream over an already-established SRT connection**.
68-
Separately confirmed by hand: running the binary against
69-
`config/example.toml` binds real UDP/SRT listener sockets (via `lsof`),
70-
the REST API and a real browser click both drive live crosspoint changes,
71-
the websocket push updates the grid with no client-side polling, and a
72-
persisted route survives a real process restart.
73-
74-
**Not yet done:** no test against a real third-party SRT
75-
encoder/decoder or over a real (non-loopback) network path — only local
76-
testing so far, still the main open gap. Also missing: special-purpose
77-
sources (stills/media player/scaler), auth on the web UI/API, external
78-
control API/Companion integration. See [docs/roadmap.md](docs/roadmap.md)
79-
for the full phased plan.
90+
integration tests (`crates/srt-io/tests/relay.rs`,
91+
`crates/ndi-io/tests/relay.rs`) that relay real protocol traffic
92+
end-to-end through the crosspoint — one SRT test also exercises a **live
93+
re-route mid-stream over an already-established connection**. Separately
94+
confirmed by hand: running the binary against `config/example.toml` binds
95+
real UDP/SRT listener sockets (via `lsof`), adding a source through the
96+
web UI binds a new one live and removing it frees the port (also via
97+
`lsof`), the REST API and a real browser click both drive live crosspoint
98+
changes, the websocket push updates the grid with no client-side polling,
99+
and a persisted route survives a real process restart.
100+
101+
**Not yet done:** no test against a real third-party SRT/NDI encoder or
102+
decoder, or over a real (non-loopback) network path — only local testing so
103+
far, still the main open gap. Also missing: NDI wired into the router's
104+
config/web UI, OMT (placeholder crate only), special-purpose sources
105+
(stills/media player/scaler — the add-source menu shows them as disabled
106+
options), auth on the web UI/API, external control API/Companion
107+
integration. See [docs/roadmap.md](docs/roadmap.md) for the full phased
108+
plan.
80109

81110
## Quick start
82111

crates/core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ license.workspace = true
88
bytes = "1"
99
tokio = { version = "1", features = ["sync"] }
1010
parking_lot = "0.12"
11+
12+
[dev-dependencies]
13+
tokio = { version = "1", features = ["sync", "macros", "rt"] }

crates/core/src/lib.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ impl Crosspoint {
6262
self.sources.read().get(source).map(|tx| tx.subscribe())
6363
}
6464

65+
/// Remove a source. Any output currently routed to it keeps its `watch`
66+
/// value pointing at the now-gone id (routing is a separate concern,
67+
/// see [`route`](Self::route)) but its `subscribe` will start returning
68+
/// `None`/closed — the caller (a `*-io` crate's output task) is expected
69+
/// to treat that the same as "nothing routed yet" until re-routed.
70+
/// Returns `true` if a source with this id existed.
71+
pub fn deregister_source(&self, id: &str) -> bool {
72+
self.sources.write().remove(id).is_some()
73+
}
74+
75+
/// Remove an output. Returns `true` if an output with this id existed.
76+
pub fn deregister_output(&self, id: &str) -> bool {
77+
self.outputs.write().remove(id).is_some()
78+
}
79+
80+
pub fn has_source(&self, id: &str) -> bool {
81+
self.sources.read().contains_key(id)
82+
}
83+
84+
pub fn has_output(&self, id: &str) -> bool {
85+
self.outputs.read().contains_key(id)
86+
}
87+
6588
/// Point an output at a different source. No-ops (returns `false`) if
6689
/// either id is unknown, so callers (e.g. the web API) can distinguish
6790
/// a bad request from a successful re-route.
@@ -126,4 +149,23 @@ mod tests {
126149
assert!(!xp.route("out1", "nonexistent"));
127150
assert!(!xp.route("nonexistent", "a"));
128151
}
152+
153+
#[test]
154+
fn deregister_removes_and_reports_prior_existence() {
155+
let xp = Crosspoint::new();
156+
xp.register_source("a");
157+
xp.register_output("out1", "a");
158+
159+
assert!(xp.has_source("a"));
160+
assert!(xp.has_output("out1"));
161+
162+
assert!(xp.deregister_source("a"));
163+
assert!(!xp.has_source("a"));
164+
assert!(!xp.deregister_source("a"), "second removal reports false");
165+
166+
assert!(xp.deregister_output("out1"));
167+
assert!(!xp.has_output("out1"));
168+
169+
assert!(xp.subscribe("a").is_none());
170+
}
129171
}

crates/router/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ path = "src/main.rs"
1212
crosspoint-core = { path = "../core" }
1313
srt-io = { path = "../srt-io" }
1414
crosspoint-web = { path = "../web" }
15+
axum = "0.7"
1516
tokio = { version = "1", features = ["full"] }
17+
tokio-util = "0.7"
18+
parking_lot = "0.12"
1619
serde = { version = "1", features = ["derive"] }
1720
serde_json = "1"
1821
toml = "0.8"

crates/router/src/main.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
mod config;
2+
mod management;
3+
mod registry;
24
mod state;
35

46
use std::collections::HashMap;
@@ -8,6 +10,8 @@ use anyhow::{Context, Result};
810
use clap::Parser;
911
use config::Config;
1012
use crosspoint_core::Crosspoint;
13+
use management::ManageState;
14+
use registry::Registry;
1115

1216
/// Crosspoint-based SRT router.
1317
#[derive(Parser)]
@@ -31,10 +35,17 @@ async fn main() -> Result<()> {
3135
.with_context(|| format!("parsing config file {}", args.config.display()))?;
3236

3337
let crosspoint = Crosspoint::new();
38+
let registry = Registry::new();
3439

40+
// Config-defined and management-API-added sources/outputs are
41+
// deliberately the same thing once running — both end up in `registry`
42+
// via the same spawn_input/spawn_output + insert_* calls, so a
43+
// config-declared source is exactly as removable via the API (or
44+
// listable in the web UI's add/remove menus) as one added later.
3545
for input in config.inputs {
3646
tracing::info!(id = %input.id, "starting SRT input");
37-
srt_io::spawn_input(input.id, input.endpoint, crosspoint.clone());
47+
let cancel = srt_io::spawn_input(input.id.clone(), input.endpoint, crosspoint.clone());
48+
registry.insert_source(input.id, "srt", cancel);
3849
}
3950

4051
let persisted_routes: HashMap<String, String> = match &config.state {
@@ -58,12 +69,13 @@ async fn main() -> Result<()> {
5869
.cloned()
5970
.unwrap_or(output.default_source);
6071
tracing::info!(id = %output.id, source = %initial_source, "starting SRT output");
61-
srt_io::spawn_output(
62-
output.id,
72+
let cancel = srt_io::spawn_output(
73+
output.id.clone(),
6374
output.endpoint,
6475
initial_source,
6576
crosspoint.clone(),
6677
);
78+
registry.insert_output(output.id, "srt", cancel);
6779
}
6880

6981
if let Some(state_cfg) = config.state {
@@ -76,6 +88,13 @@ async fn main() -> Result<()> {
7688
.parse()
7789
.with_context(|| format!("invalid web.bind address {:?}", config.web.bind))?;
7890

79-
crosspoint_web::serve(bind, crosspoint).await?;
91+
let manage_state = ManageState {
92+
crosspoint: crosspoint.clone(),
93+
registry,
94+
};
95+
let app = crosspoint_web::app(crosspoint).merge(management::router(manage_state));
96+
let listener = tokio::net::TcpListener::bind(bind).await?;
97+
tracing::info!(%bind, "crosspoint web UI listening");
98+
axum::serve(listener, app).await?;
8099
Ok(())
81100
}

0 commit comments

Comments
 (0)