Просмотр исходного кода

web: build-version badge under the dashboard title

The binary now carries the version it was built as: the add-on CI passes
SM121_VERSION as a docker build arg, the launcher reads it with option_env!
(src/build_info.rs, 'dev' for a plain cargo build) and ships it in the state
snapshot. The dashboard renders it as a small muted badge right under 'sm121',
so an operator can tell which release a running dashboard is without opening HA.

Tested: the state endpoint reports the baked value and the page carries the
badge node; a runtime env var deliberately does nothing (build-time identity).
Alexander Korolev 7 часов назад
Родитель
Сommit
a9cffba5fa
5 измененных файлов с 72 добавлено и 1 удалено
  1. 23 0
      src/build_info.rs
  2. 1 0
      src/main.rs
  3. 5 0
      src/web/events.rs
  4. 34 0
      src/web/tests.rs
  5. 9 1
      src/web/ui.html

+ 23 - 0
src/build_info.rs

@@ -0,0 +1,23 @@
+//! Build-time identity of this binary.
+//!
+//! The add-on image is built with `SM121_VERSION=<add-on version>` (a Docker
+//! build arg the CI passes from `sm121/config.yaml`), so the running dashboard
+//! can show which release it is. A plain `cargo build` reports `dev`.
+//!
+//! `option_env!` rather than `env!`: a local build has no such variable and
+//! must still compile.
+
+/// The version baked in at build time (`dev` for a local build).
+pub fn version() -> &'static str {
+    option_env!("SM121_VERSION").unwrap_or("dev")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::version;
+
+    #[test]
+    fn version_is_never_empty() {
+        assert!(!version().is_empty());
+    }
+}

+ 1 - 0
src/main.rs

@@ -27,6 +27,7 @@ mod bench_io;
 mod bench_remote;
 mod bench_report;
 mod bench_target;
+mod build_info;
 mod builtin_scripts;
 mod cli;
 mod cli_sub;

+ 5 - 0
src/web/events.rs

@@ -85,6 +85,10 @@ pub struct AttachSnap {
 #[derive(Serialize, Clone, Debug)]
 pub struct Snapshot {
     pub ts: u64,
+    /// The launcher build version (`SM121_VERSION` baked in at image build;
+    /// `dev` for a local build). Shown as a badge under the dashboard title.
+    #[serde(default)]
+    pub version: String,
     pub clusters: Vec<ClusterSnap>,
     /// The fixed physical map (nodes, cables, HA glyphs).
     pub topo: crate::web::topo::TopoSnap,
@@ -170,6 +174,7 @@ pub fn build_snapshot(
         .collect();
     Snapshot {
         ts: now_ts(),
+        version: crate::build_info::version().to_string(),
         clusters,
         topo,
         jobs: jobs.to_vec(),

+ 34 - 0
src/web/tests.rs

@@ -197,6 +197,7 @@ fn stale_from_snapshot_picks_unreachable_nodes() {
         ha: None,
     };
     let snap = crate::web::events::Snapshot {
+        version: String::new(),
         ts: 1,
         clusters: vec![crate::web::events::ClusterSnap {
             name: "c".into(),
@@ -219,6 +220,7 @@ fn stale_from_snapshot_picks_unreachable_nodes() {
 async fn token_gate_covers_recipes_panel() {
     let app = app_state(Some("s3cret"));
     *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
+        version: String::new(),
         ts: 1,
         clusters: Vec::new(),
         jobs: Vec::new(),
@@ -280,6 +282,7 @@ async fn sse_replays_cached_snapshot_on_connect() {
     // cached snapshot, otherwise a fresh client waits a whole poll tick.
     let app = app_state(None);
     *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
+        version: String::new(),
         ts: 1,
         clusters: Vec::new(),
         jobs: Vec::new(),
@@ -305,6 +308,7 @@ async fn sse_replays_cached_snapshot_on_connect() {
 async fn bus_delivers_state_events() {
     let (tx, mut rx) = tokio::sync::broadcast::channel::<Event>(8);
     let snap = crate::web::events::Snapshot {
+        version: String::new(),
         ts: 1,
         clusters: Vec::new(),
         jobs: Vec::new(),
@@ -327,6 +331,36 @@ async fn bus_delivers_state_events() {
     }
 }
 
+/// The dashboard header carries a small version badge under the title; the
+/// version itself is baked in at image build (`SM121_VERSION`) and reaches the
+/// page through the state snapshot, so a plain `cargo build` shows `dev`.
+#[test]
+fn header_shows_the_build_version() {
+    use crate::web::events::build_snapshot;
+    let snap = build_snapshot(
+        &fake_config(),
+        &crate::web::probe::Fleet {
+            nodes: std::collections::HashMap::new(),
+            switch_off: std::collections::HashMap::new(),
+            soft_off: std::collections::HashMap::new(),
+            ha: std::collections::HashMap::new(),
+        },
+        &[],
+        &[],
+        &std::collections::HashMap::new(),
+    );
+    assert_eq!(snap.version, crate::build_info::version());
+    let json = serde_json::to_string(&snap).unwrap();
+    assert!(
+        json.contains("\"version\""),
+        "state JSON has no version: {json}"
+    );
+    let page = super::UI_HTML;
+    for needle in ["id=\"ver\"", "header .ver", "v\" + snap.version"] {
+        assert!(page.contains(needle), "dashboard page missing {needle}");
+    }
+}
+
 #[tokio::test]
 async fn snapshot_serializes_with_type_tag() {
     let ev = Event::State {

+ 9 - 1
src/web/ui.html

@@ -39,6 +39,9 @@
     background: var(--panel); position: sticky; top: 0; z-index: 5;
   }
   header h1 { font-size: 15px; margin: 0; font-weight: 650; letter-spacing: .3px; }
+  header .brand { display: flex; flex-direction: column; line-height: 1.05; }
+  header .ver { font-size: 9.5px; color: var(--muted); letter-spacing: .4px;
+                font-variant: small-caps; user-select: text; }
   header .spacer { flex: 1; }
   .ssec { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
   .ssec .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-off); }
@@ -397,7 +400,7 @@
 </head>
 <body>
 <header>
-  <h1>sm121</h1>
+  <div class="brand"><h1>sm121</h1><span class="ver" id="ver" hidden></span></div>
   <div id="maxinfo" hidden><span>⛶</span><span class="maxtitle" id="max-title"></span>
     <button class="wbtn" id="max-restore" title="restore window size">❐</button>
     <button class="wbtn" id="max-min" title="minimize">–</button>
@@ -1065,6 +1068,11 @@ function abtn(id, label, fn, cls) {
 }
 function onState(snap, force) {
   const prev = state.snap;
+  const ver = document.getElementById("ver");
+  if (ver && snap.version && ver.textContent !== "v" + snap.version) {
+    ver.textContent = "v" + snap.version;
+    ver.hidden = false;
+  }
   updateLogNotes(snap); // runs even on no-churn ticks (attaches change out-of-band)
   if (!force && prev && sameSnapshot(prev, snap)) {
     state.snap = snap; // adopt the fresh attaches state; no DOM churn