mod.rs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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. use crate::config::Config;
  17. use crate::web::events::{Event, build_snapshot};
  18. use axum::{
  19. Router,
  20. extract::State,
  21. http::{HeaderMap, Request, StatusCode},
  22. middleware::{self, Next},
  23. response::{
  24. Html, Response,
  25. sse::{Event as SseEvent, Sse},
  26. },
  27. routing::{get, post},
  28. };
  29. use std::net::SocketAddr;
  30. use std::sync::{Arc, Mutex};
  31. use tokio::sync::broadcast;
  32. use tokio::time::Duration;
  33. const UI_HTML: &str = include_str!("ui.html");
  34. const POLL_SECS: u64 = 30;
  35. const BUS_CAP: usize = 1024;
  36. #[derive(Clone)]
  37. pub struct AppState {
  38. pub config: Arc<Config>,
  39. pub bus: broadcast::Sender<Event>,
  40. pub token: Option<String>,
  41. /// Latest snapshot (written by the supervisor; `/api/state` serves it).
  42. pub last: Arc<Mutex<Option<events::Snapshot>>>,
  43. /// Tracked action jobs (serve/stop/power); capped at `jobs::MAX_JOBS`.
  44. pub jobs: Arc<Mutex<Vec<jobs::Job>>>,
  45. /// Tracked log streams; capped at `logs::MAX_ATTACHES`.
  46. pub log_attaches: Arc<Mutex<Vec<logs::LogAttach>>>,
  47. }
  48. /// Probe the fleet and broadcast a fresh state snapshot (supervisor tick
  49. /// and job-finish both route through here).
  50. pub(crate) async fn push_state(app: &AppState) {
  51. let fleet = probe::probe(&app.config).await;
  52. let jobs = app.jobs.lock().unwrap().clone();
  53. let attaches: Vec<events::AttachSnap> = app
  54. .log_attaches
  55. .lock()
  56. .unwrap()
  57. .iter()
  58. .map(|a| events::AttachSnap {
  59. id: a.id.clone(),
  60. cluster: a.cluster.clone(),
  61. node: a.node.clone(),
  62. recipe: a.recipe.clone(),
  63. rank: a.rank,
  64. done: a.done.load(std::sync::atomic::Ordering::Relaxed),
  65. })
  66. .collect();
  67. let snap = build_snapshot(&app.config, &fleet, &jobs, &attaches);
  68. let _ = app.bus.send(Event::State {
  69. ts: snap.ts,
  70. snapshot: snap.clone(),
  71. });
  72. *app.last.lock().unwrap() = Some(snap);
  73. }
  74. /// `GET /events` — forward every bus event as an SSE message.
  75. async fn events_stream(
  76. app: State<AppState>,
  77. ) -> Sse<impl futures::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
  78. let rx = app.bus.subscribe();
  79. Sse::new(futures::stream::unfold(rx, |mut rx| async move {
  80. loop {
  81. match rx.recv().await {
  82. Ok(ev) => {
  83. let json = serde_json::to_string(&ev).unwrap_or_default();
  84. return Some((Ok(SseEvent::default().data(json)), rx));
  85. }
  86. Err(broadcast::error::RecvError::Lagged(n)) => {
  87. tracing::debug!("SSE client lagged {n} events");
  88. // Same receiver is still valid — keep reading.
  89. }
  90. Err(broadcast::error::RecvError::Closed) => return None,
  91. }
  92. }
  93. }))
  94. }
  95. /// Bearer-token gate for data endpoints (`/` stays public — it is static
  96. /// HTML with no secrets). Accepts `Authorization: Bearer <t>` or `?token=<t>`
  97. /// (SSE `EventSource` cannot set headers, so the UI uses the query form).
  98. async fn token_guard(
  99. State(app): State<AppState>,
  100. headers: HeaderMap,
  101. req: Request<axum::body::Body>,
  102. next: Next,
  103. ) -> Result<Response, (StatusCode, String)> {
  104. if let Some(expected) = &app.token {
  105. // ponytail: raw query param, no percent-decoding — tokens are
  106. // operator-generated; decode if exotic tokens are ever used.
  107. let from_query = req.uri().query().and_then(|q| {
  108. q.split('&').find_map(|kv| {
  109. kv.split_once('=')
  110. .and_then(|(k, v)| (k == "token").then_some(v))
  111. })
  112. });
  113. let got = headers
  114. .get("authorization")
  115. .and_then(|v| v.to_str().ok())
  116. .and_then(|v| v.strip_prefix("Bearer "))
  117. .or(from_query);
  118. if got != Some(expected.as_str()) {
  119. return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
  120. }
  121. }
  122. Ok(next.run(req).await)
  123. }
  124. /// Supervisor: poll the fleet and broadcast a state snapshot every tick.
  125. async fn supervisor(app: AppState) {
  126. loop {
  127. push_state(&app).await;
  128. tokio::time::sleep(Duration::from_secs(POLL_SECS)).await;
  129. }
  130. }
  131. fn is_loopback(addr: SocketAddr) -> bool {
  132. addr.ip().is_loopback()
  133. }
  134. /// Build the router (separate from `run` so tests can drive it without
  135. /// binding a port).
  136. pub fn router(app: AppState) -> Router {
  137. let protected = Router::new()
  138. .route("/api/state", get(api::state))
  139. .route("/api/serve", post(api::serve))
  140. .route("/api/stop", post(api::stop))
  141. .route("/api/power", post(api::power))
  142. .route("/api/recipes", get(api::recipes))
  143. .route(
  144. "/api/logs",
  145. get(api::logs_history)
  146. .post(api::logs_attach)
  147. .delete(api::logs_detach),
  148. )
  149. .route("/api/raw/config", get(api::raw_config))
  150. .route("/api/raw/recipe/{name}", get(api::raw_recipe))
  151. .route("/api/raw/effective", get(api::raw_effective))
  152. .route("/events", get(events_stream))
  153. .layer(middleware::from_fn_with_state(app.clone(), token_guard));
  154. Router::new()
  155. .route("/", get(|| async { Html(UI_HTML) }))
  156. .merge(protected)
  157. .with_state(app)
  158. }
  159. /// Run the dashboard until the process exits.
  160. pub async fn run(
  161. config: Config,
  162. port: u16,
  163. bind: String,
  164. token: Option<String>,
  165. open: bool,
  166. ) -> anyhow::Result<()> {
  167. let addr: SocketAddr = format!("{bind}:{port}").parse()?;
  168. if !is_loopback(addr) && token.is_none() {
  169. anyhow::bail!(
  170. "refusing to bind {bind} (non-loopback) without --token — the dashboard can act on the cluster"
  171. );
  172. }
  173. let (tx, _) = broadcast::channel(BUS_CAP);
  174. let app = AppState {
  175. config: Arc::new(config),
  176. bus: tx,
  177. token,
  178. last: Arc::new(Mutex::new(None)),
  179. jobs: Arc::new(Mutex::new(Vec::new())),
  180. log_attaches: Arc::new(Mutex::new(Vec::new())),
  181. };
  182. tokio::spawn(supervisor(app.clone()));
  183. let listener = tokio::net::TcpListener::bind(addr).await?;
  184. if open {
  185. let url = format!("http://{addr}/");
  186. tokio::spawn(async move {
  187. tokio::time::sleep(Duration::from_millis(400)).await;
  188. let prog = if cfg!(target_os = "macos") {
  189. "open"
  190. } else {
  191. "xdg-open"
  192. };
  193. let _ = std::process::Command::new(prog).arg(&url).spawn();
  194. });
  195. }
  196. tracing::info!("sm121 web: http://{addr}/ (poll every {POLL_SECS}s)");
  197. axum::serve(listener, router(app)).await?;
  198. Ok(())
  199. }
  200. #[cfg(test)]
  201. mod api_tests;
  202. #[cfg(test)]
  203. mod tests;