mod.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. //! `sm121 web` — axum + SSE dashboard.
  2. //!
  3. //! Read-only LED board (state snapshots every 30 s + on job finish),
  4. //! actions — serve/stop/power run as tracked jobs calling the same
  5. //! handlers the CLI uses — and live log windows (SSH `docker logs -f`
  6. //! attaches with ring-buffer history). Data endpoints are token-gated
  7. //! when `--token` is set (required when binding off-loopback).
  8. //!
  9. //! Design: `specs/tech-architecture/web-dashboard.md`.
  10. mod api;
  11. pub mod events;
  12. pub mod jobs;
  13. pub mod logs;
  14. pub mod probe;
  15. pub mod ssh_stream;
  16. pub mod topo;
  17. use crate::config::Config;
  18. use crate::web::events::{Event, build_snapshot};
  19. use axum::{
  20. Router,
  21. extract::State,
  22. http::{HeaderMap, Request, StatusCode},
  23. middleware::{self, Next},
  24. response::{
  25. Html, Response,
  26. sse::{Event as SseEvent, KeepAlive, Sse},
  27. },
  28. routing::{get, post},
  29. };
  30. use std::net::SocketAddr;
  31. use std::sync::{Arc, Mutex};
  32. use tokio::sync::broadcast;
  33. use tokio::time::Duration;
  34. const UI_HTML: &str = include_str!("ui.html");
  35. const POLL_SECS: u64 = 30;
  36. const BUS_CAP: usize = 1024;
  37. #[derive(Clone)]
  38. pub struct AppState {
  39. pub config: Arc<Config>,
  40. pub bus: broadcast::Sender<Event>,
  41. pub token: Option<String>,
  42. /// Latest snapshot (written by the supervisor; `/api/state` serves it).
  43. pub last: Arc<Mutex<Option<events::Snapshot>>>,
  44. /// Tracked action jobs (serve/stop/power); capped at `jobs::MAX_JOBS`.
  45. pub jobs: Arc<Mutex<Vec<jobs::Job>>>,
  46. /// Tracked log streams; capped at `logs::MAX_ATTACHES`.
  47. pub log_attaches: Arc<Mutex<Vec<logs::LogAttach>>>,
  48. }
  49. /// Probe the fleet and broadcast a fresh state snapshot (supervisor tick
  50. /// and job-finish both route through here).
  51. pub(crate) async fn push_state(app: &AppState) {
  52. let fleet = probe::probe(&app.config).await;
  53. let jobs = app.jobs.lock().unwrap().clone();
  54. let attaches: Vec<events::AttachSnap> = app
  55. .log_attaches
  56. .lock()
  57. .unwrap()
  58. .iter()
  59. .map(|a| events::AttachSnap {
  60. id: a.id.clone(),
  61. cluster: a.cluster.clone(),
  62. node: a.node.clone(),
  63. recipe: a.recipe.clone(),
  64. rank: a.rank,
  65. done: a.done.load(std::sync::atomic::Ordering::Relaxed),
  66. })
  67. .collect();
  68. let snap = build_snapshot(&app.config, &fleet, &jobs, &attaches, &fleet.ha);
  69. let _ = app.bus.send(Event::State {
  70. ts: snap.ts,
  71. snapshot: snap.clone(),
  72. });
  73. *lock_last(app) = Some(snap);
  74. }
  75. /// `GET /events` — forward every bus event as an SSE message. A fresh
  76. /// client gets the cached snapshot first, so it paints (and flips its
  77. /// live indicator) at once instead of waiting for the next poll tick.
  78. async fn events_stream(
  79. app: State<AppState>,
  80. ) -> Sse<impl futures::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
  81. let head = app
  82. .last
  83. .lock()
  84. .unwrap()
  85. .clone()
  86. .map(|snapshot| Event::State {
  87. ts: snapshot.ts,
  88. snapshot,
  89. });
  90. let rx = app.bus.subscribe();
  91. Sse::new(futures::stream::unfold(
  92. (rx, head),
  93. |(mut rx, mut head)| async move {
  94. if let Some(ev) = head.take() {
  95. return Some((sse_event(&ev), (rx, None)));
  96. }
  97. loop {
  98. match rx.recv().await {
  99. Ok(ev) => return Some((sse_event(&ev), (rx, None))),
  100. Err(broadcast::error::RecvError::Lagged(n)) => {
  101. tracing::debug!("SSE client lagged {n} events");
  102. // Same receiver is still valid — keep reading.
  103. }
  104. Err(broadcast::error::RecvError::Closed) => return None,
  105. }
  106. }
  107. },
  108. ))
  109. // comment lines between events: NAT/VPN/proxy idle timers (and frozen
  110. // tabs on reconnect) see traffic, so a dead stream is noticed in seconds,
  111. // not minutes
  112. .keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
  113. }
  114. fn sse_event(ev: &Event) -> Result<SseEvent, std::convert::Infallible> {
  115. Ok(SseEvent::default().data(serde_json::to_string(ev).unwrap_or_default()))
  116. }
  117. /// Bearer-token gate for data endpoints (`/` stays public — it is static
  118. /// HTML with no secrets). Accepts `Authorization: Bearer <t>` or `?token=<t>`
  119. /// (SSE `EventSource` cannot set headers, so the UI uses the query form).
  120. async fn token_guard(
  121. State(app): State<AppState>,
  122. headers: HeaderMap,
  123. req: Request<axum::body::Body>,
  124. next: Next,
  125. ) -> Result<Response, (StatusCode, String)> {
  126. if let Some(expected) = &app.token {
  127. // ponytail: raw query param, no percent-decoding — tokens are
  128. // operator-generated; decode if exotic tokens are ever used.
  129. let from_query = req.uri().query().and_then(|q| {
  130. q.split('&').find_map(|kv| {
  131. kv.split_once('=')
  132. .and_then(|(k, v)| (k == "token").then_some(v))
  133. })
  134. });
  135. let got = headers
  136. .get("authorization")
  137. .and_then(|v| v.to_str().ok())
  138. .and_then(|v| v.strip_prefix("Bearer "))
  139. .or(from_query);
  140. if got != Some(expected.as_str()) {
  141. return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
  142. }
  143. }
  144. Ok(next.run(req).await)
  145. }
  146. /// Supervisor: poll the fleet and broadcast a state snapshot every tick.
  147. async fn supervisor(app: AppState) {
  148. loop {
  149. // isolate each tick: a probe bug or a poisoned lock must not kill
  150. // the poll loop for good (a dead supervisor means a dead live feed)
  151. let tick = app.clone();
  152. if let Err(e) = tokio::spawn(async move { push_state(&tick).await }).await {
  153. tracing::warn!("sm121 web: supervisor tick panicked: {e}");
  154. }
  155. tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
  156. }
  157. }
  158. /// `AppState.last` guard that survives a poisoned mutex (a panic while
  159. /// holding the lock would otherwise take the whole live feed down).
  160. fn lock_last(app: &AppState) -> std::sync::MutexGuard<'_, Option<events::Snapshot>> {
  161. app.last
  162. .lock()
  163. .unwrap_or_else(std::sync::PoisonError::into_inner)
  164. }
  165. fn is_loopback(addr: SocketAddr) -> bool {
  166. addr.ip().is_loopback()
  167. }
  168. /// Build the router (separate from `run` so tests can drive it without
  169. /// binding a port).
  170. pub fn router(app: AppState) -> Router {
  171. let protected = Router::new()
  172. .route("/api/state", get(api::state))
  173. .route("/api/serve", post(api::serve))
  174. .route("/api/stop", post(api::stop))
  175. .route("/api/power", post(api::power))
  176. .route("/api/recipes", get(api::recipes))
  177. .route(
  178. "/api/logs",
  179. get(api::logs_history)
  180. .post(api::logs_attach)
  181. .delete(api::logs_detach),
  182. )
  183. .route("/api/raw/config", get(api::raw_config))
  184. .route("/api/raw/recipe/{name}", get(api::raw_recipe))
  185. .route("/api/raw/effective", get(api::raw_effective))
  186. .route("/events", get(events_stream))
  187. .layer(middleware::from_fn_with_state(app.clone(), token_guard));
  188. // Recipes panel: the very page `sm121 report --serve` serves, nested at
  189. // `/recipes` — its own SSE stream and `/r/<recipe>/<file>` viewer move
  190. // under the prefix (see `report::html_page_at`), live refresh included.
  191. let rows = crate::report::collect(&app.config, false);
  192. let recipes = crate::report::serve::router(
  193. app.config.clone(),
  194. crate::report::html_page_at(&rows, "/recipes"),
  195. serde_json::to_string(&rows).unwrap_or_default(),
  196. false,
  197. )
  198. .layer(middleware::from_fn_with_state(app.clone(), token_guard));
  199. Router::new()
  200. .route("/", get(|| async { Html(UI_HTML) }))
  201. .merge(protected)
  202. .with_state(app)
  203. .nest("/recipes", recipes)
  204. }
  205. /// Run the dashboard until the process exits.
  206. pub async fn run(
  207. config: Config,
  208. port: u16,
  209. bind: String,
  210. token: Option<String>,
  211. open: bool,
  212. ) -> anyhow::Result<()> {
  213. let addr: SocketAddr = format!("{bind}:{port}").parse()?;
  214. if !is_loopback(addr) && token.is_none() {
  215. anyhow::bail!(
  216. "refusing to bind {bind} (non-loopback) without --token — the dashboard can act on the cluster"
  217. );
  218. }
  219. let (tx, _) = broadcast::channel(BUS_CAP);
  220. let app = AppState {
  221. config: Arc::new(config),
  222. bus: tx,
  223. token,
  224. last: Arc::new(Mutex::new(None)),
  225. jobs: Arc::new(Mutex::new(Vec::new())),
  226. log_attaches: Arc::new(Mutex::new(Vec::new())),
  227. };
  228. tokio::spawn(supervisor(app.clone()));
  229. let listener = tokio::net::TcpListener::bind(addr).await?;
  230. if open {
  231. let url = format!("http://{addr}/");
  232. tokio::spawn(async move {
  233. tokio::time::sleep(Duration::from_millis(400)).await;
  234. let prog = if cfg!(target_os = "macos") {
  235. "open"
  236. } else {
  237. "xdg-open"
  238. };
  239. let _ = std::process::Command::new(prog).arg(&url).spawn();
  240. });
  241. }
  242. tracing::info!("sm121 web: http://{addr}/ (poll every {POLL_SECS}s)");
  243. axum::serve(listener, router(app)).await?;
  244. Ok(())
  245. }
  246. #[cfg(test)]
  247. mod api_tests;
  248. #[cfg(test)]
  249. mod tests;
  250. #[cfg(test)]
  251. mod ui_js_tests;