فهرست منبع

recipes panel: manual repo-sync button, per-row serve/stop actions, fleet-based running state (no-ledger machines), dock menu anchored to the + button

Alexander Korolev 1 هفته پیش
والد
کامیت
f00b155eda
10فایلهای تغییر یافته به همراه179 افزوده شده و 7 حذف شده
  1. 13 1
      AGENTS.md
  2. 10 1
      src/report/mod.rs
  3. 54 5
      src/report/page.html
  4. 3 0
      src/report/serve.rs
  5. 32 0
      src/report/stale.rs
  6. 17 0
      src/report/ui_tests.rs
  7. 22 0
      src/web/api.rs
  8. 17 0
      src/web/api_tests.rs
  9. 1 0
      src/web/mod.rs
  10. 10 0
      src/web/ui.html

+ 13 - 1
AGENTS.md

@@ -118,7 +118,19 @@ Provenance column. Old runs without a package show `—` / no chip.
 
 `sm121 web` — local axum + SSE dashboard: **recipes panel** window (the
 `sm121 report --serve` page mounted at `/recipes` — one implementation, its
-own SSE stream and `/r/` viewer prefix-rewritten server-side), topology map
+own SSE stream and `/r/` viewer prefix-rewritten server-side). In hosted
+mode the panel is interactive: a **⟳ sync** button (POST `/api/repos/sync` —
+the same best-effort batch as the command-start sync, single in-flight
+409 guard; the refresher's file watcher picks up new recipes on its own),
+and **per-row serve/stop action buttons** (POST `/api/serve` / `/api/stop`
+with `cluster: "auto"` — the same tracked jobs the CLI uses; the state cell
+flips when the fleet probe sees the change). Standing state is
+**fleet-aware**: a recipe the live probe sees running but the local ledger
+knows nothing about (the add-on machine has no `.lock` — they are
+gitignored and never ship with the synced repo) renders green
+"up (fleet)"; the launch-count column shows `—` without a ledger. The
+standalone `report --serve` has no job/repo API, so the buttons stay
+hidden there. topology map
 (physical layer:
 8 GX10 boxes on a 2×4 grid, 200G fabric switch + passive 10G switch + DAC
 direct links derived from config `ib_ip`/`extra_entities`, HA glyphs with

+ 10 - 1
src/report/mod.rs

@@ -106,9 +106,17 @@ pub(crate) struct ReportRow {
     /// `sm121 runlock close` hint, instead of the green "up".
     #[serde(default, skip_serializing_if = "is_false")]
     stale: bool,
-    /// Lifetime launch count from the .lock ledger.
+    /// Lifetime launch count from the .lock ledger (0 when the machine has
+    /// no ledger — e.g. the add-on's synced recipe checkout: the .lock files
+    /// are gitignored and never ship with the repo).
     #[serde(default)]
     runs: u64,
+    /// The live fleet probe sees a running container for this recipe, but
+    /// the local ledger knows no open run (a foreign launcher served it —
+    /// the add-on on the Pi has no ledgers at all). Rendered green "up"
+    /// (fleet), so a remote serve is visible without a local .lock.
+    #[serde(default, skip_serializing_if = "is_false")]
+    fleet_up: bool,
     /// Family / model / per-TP medal ranks (see [`quality::Medals`]).
     #[serde(default)]
     medals: quality::Medals,
@@ -319,6 +327,7 @@ pub(crate) fn collect(config: &Config, show_archived: bool) -> Vec<ReportRow> {
                 up_cluster: lock.running.as_ref().and_then(|r| r.cluster.clone()),
                 stale: false,
                 runs: lock.total,
+                fleet_up: false,
                 medals: quality::Medals::default(),
                 reports,
             });

+ 54 - 5
src/report/page.html

@@ -93,6 +93,7 @@
     <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
   </label>
   <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
+  <button id="syncbtn" title="sync the recipe repos now (dashboard job; new recipes appear via the live stream)" style="display:none">⟳ sync</button>
   <span id="live" class="dim" title="live updates">● off</span>
   <button id="bell" title="updates" aria-label="updates">
     <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
@@ -182,7 +183,7 @@ function cell(r, k) {
   if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
   if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
   if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
-  if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : -1; // live uptime secs
+  if (k === "state" || k === "uptime") return r.up != null ? nowSecs() - r.up : r.fleet_up ? 0 : -1; // live uptime secs (fleet-up: unknown start)
   if (k === "runs") return r.runs ?? 0;
   return r[k];
 }
@@ -378,7 +379,7 @@ const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":
 function filtered() {
   const q = S.q.toLowerCase();
   return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
-    (!S.uponly || r.up != null) &&
+    (!S.uponly || r.up != null || r.fleet_up) &&
     (!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
     (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
     r.engine + " " + r.image + " " + r.deploy + " " +
@@ -469,11 +470,15 @@ function render() {
           ? (r.stale
               ? `<span title="ledger says running but no live container found (node down? killed serve?) — verify and close: sm121 runlock close ${esc(r.name)}"><span style="color:#d2822a">●</span><span class="dim"> stale</span></span>`
               : `<span title="up since ${fmtSince(r.up)} UTC"><span style="color:green">●</span><span class="dim"> up</span></span>`)
-          : '<span class="dim" title="not running">○</span>';
+          : (r.fleet_up
+              ? `<span title="running in the fleet — a container was found on a cluster node, but this machine has no local run ledger (served by another launcher)"><span style="color:green">●</span><span class="dim"> up</span><span class="dim" style="font-size:.8em">(fleet)</span></span>`
+              : '<span class="dim" title="not running">○</span>')
+            + actions(r);
       } else if (c[0] === "uptime") {
         body = r.up != null ? fmtUp(v) : '<span class="dim">—</span>';
       } else if (c[0] === "runs") {
-        body = v > 0 ? esc(v) : '<span class="dim">0</span>';
+        body = v > 0 ? esc(v)
+          : '<span class="dim" title="no launch ledger on this machine (or never launched)">—</span>';
       } else if (v === "" || v == null) {
         body = '<span class="dim">—</span>';
       } else if (c[0] === "name") {
@@ -546,10 +551,54 @@ document.getElementById("medal").addEventListener("change", e => { S.medal = e.t
 document.getElementById("uponly").addEventListener("change", e => { S.uponly = e.target.checked; S.page = 0; render(); });
 // live uptime: re-render while anything is running (the cell values are
 // computed at render time)
-setInterval(() => { if (DATA.some(r => r.up != null)) render(); }, 30000);
+setInterval(() => { if (DATA.some(r => r.up != null || r.fleet_up)) render(); }, 30000);
 document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
 document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
 document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
+// ---- dashboard actions (hosted/embed mode only) --------------------------
+// The standalone `report --serve` has no job/repo API — the buttons exist
+// only when the page is mounted by the web dashboard (body.embed), and they
+// call the dashboard's same-origin endpoints (the session cookie rides
+// along). State flips come back through the fleet-probe live stream.
+const EMBED = document.body.classList.contains("embed");
+function actions(r) {
+  if (!EMBED) return "";
+  const running = r.up != null || r.fleet_up;
+  return running
+    ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹</button>`
+    : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶</button>`;
+}
+document.addEventListener("click", async (e) => {
+  const b = e.target.closest("button.act");
+  if (!b) return;
+  e.stopPropagation(); // no row toggle under the action button
+  const kind = b.dataset.act, recipe = b.dataset.r;
+  b.disabled = true;
+  const label0 = b.textContent;
+  try {
+    const res = await fetch(kind, { // "/api/serve" | "/api/stop"
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ cluster: "auto", recipe }),
+    });
+    const j = await res.json().catch(() => ({}));
+    if (j.job != null) { b.textContent = "…"; b.title = `job ${j.job} running — the state cell updates when the fleet probe sees it`; }
+    else { b.textContent = "✗"; b.title = j.error || String(res.status); }
+  } catch (err) { b.textContent = "✗"; b.title = String(err); }
+  setTimeout(() => { b.disabled = false; b.textContent = label0; b.title = kind === "stop" ? "stop (dashboard job)" : "serve (dashboard job, cluster auto)"; }, 5000);
+});
+const syncBtn = document.getElementById("syncbtn");
+if (EMBED) syncBtn.style.display = "";
+syncBtn.addEventListener("click", async () => {
+  syncBtn.disabled = true; syncBtn.textContent = "⟳ …";
+  try {
+    const res = await fetch("/api/repos/sync", { method: "POST" });
+    const j = await res.json().catch(() => ({}));
+    syncBtn.textContent = j.ok ? `⟳ ${j.repos} ok` : "⟳ ✗";
+    syncBtn.title = j.detail || (j.ok ? "recipe repos synced" : String(res.status));
+  } catch (err) { syncBtn.textContent = "⟳ ✗"; syncBtn.title = String(err); }
+  setTimeout(() => { syncBtn.disabled = false; syncBtn.textContent = "⟳ sync"; }, 6000);
+});
 // column checkboxes
 document.getElementById("colbox").innerHTML = COLS.map(c =>
   `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");

+ 3 - 0
src/report/serve.rs

@@ -201,6 +201,9 @@ async fn cross_check(
         }
     };
     crate::report::stale::mark_stale(rows, cluster_nodes, &reachable, &live);
+    // The inverse direction: a container in the fleet with NO local ledger
+    // run (the add-on machine) still renders as running.
+    crate::report::stale::mark_fleet_up(rows, &live);
 }
 
 /// `GET /events` — the latest rows JSON on connect, then one message per

+ 32 - 0
src/report/stale.rs

@@ -48,6 +48,20 @@ pub(crate) fn mark_stale(
     }
 }
 
+/// The inverse of [`mark_stale`]: a recipe the live probe sees running but
+/// the local ledger knows nothing about (no `.lock` on this machine — the
+/// add-on's synced recipe checkout never carries the gitignored ledgers).
+/// Marks `fleet_up` so the row renders green "up" from fleet evidence alone.
+/// A ledger run that IS open always wins (the row stays `up` with its
+/// start time); `stale` and `fleet_up` are mutually exclusive by
+/// construction (stale = ledger open + no container; fleet_up = no ledger +
+/// container present).
+pub(crate) fn mark_fleet_up(rows: &mut [ReportRow], live_recipes: &HashSet<String>) {
+    for row in rows {
+        row.fleet_up = row.up.is_none() && !row.stale && live_recipes.contains(&row.name);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -70,6 +84,7 @@ mod tests {
             up,
             up_cluster: up_cluster.map(String::from),
             runs: 1,
+            fleet_up: false,
             medals: Medals::default(),
             stale: false,
             reports: vec![],
@@ -116,4 +131,21 @@ mod tests {
         mark_stale(&mut rows, &cn, &reach, &HashSet::new());
         assert!(!rows[0].stale && !rows[1].stale && !rows[2].stale);
     }
+
+    #[test]
+    fn fleet_container_without_ledger_marks_fleet_up() {
+        let mut rows = vec![row("r-a", None, None), row("r-b", None, None)];
+        mark_fleet_up(&mut rows, &HashSet::from(["r-a".to_string()]));
+        assert!(rows[0].fleet_up, "live container, no ledger -> fleet_up");
+        assert!(!rows[1].fleet_up);
+    }
+
+    #[test]
+    fn open_ledger_run_is_not_fleet_up() {
+        // a ledger run (with or without a live container) keeps its own
+        // state: green "up" or stale — fleet_up only fills the ledger gap
+        let mut rows = vec![row("r-a", Some(1000), Some("c1"))];
+        mark_fleet_up(&mut rows, &HashSet::from(["r-a".to_string()]));
+        assert!(!rows[0].fleet_up);
+    }
 }

+ 17 - 0
src/report/ui_tests.rs

@@ -22,6 +22,23 @@ fn page_has_bell_and_notif_panel() {
     }
 }
 
+/// Dashboard-mode affordances: the manual repo-sync button (POST
+/// /api/repos/sync) and the per-row serve/stop action buttons; the
+/// fleet-derived running state (no local ledger) must render too.
+#[test]
+fn page_has_sync_button_and_row_actions() {
+    let page = html_page(&[]);
+    for needle in [
+        "id=\"syncbtn\"",
+        "/api/repos/sync",
+        "button.act",
+        "data-act=\"stop\"",
+        "fleet_up",
+    ] {
+        assert!(page.contains(needle), "page missing {needle}");
+    }
+}
+
 /// The stale branch of the state cell: an open ledger run the live probe
 /// contradicts renders orange with the `sm121 runlock close` hint.
 #[test]

+ 22 - 0
src/web/api.rs

@@ -235,6 +235,28 @@ pub(crate) async fn recipes(app: State<AppState>) -> Json<serde_json::Value> {
     Json(serde_json::json!({ "recipes": out }))
 }
 
+/// `POST /api/repos/sync` — clone/pull every configured recipe repo right
+/// now (the same best-effort batch the command start runs; unreachable
+/// repos are skipped, never fatal). A single in-flight guard: a second
+/// sync while one is running gets a 409, not a second git storm. The
+/// recipes panel's ⟳ button calls it; the report refresher's file watcher
+/// then picks up the new/changed recipes on its own.
+pub(crate) async fn repos_sync(app: State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
+    static SYNCING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+    if SYNCING.swap(true, std::sync::atomic::Ordering::SeqCst) {
+        return Err((
+            StatusCode::CONFLICT,
+            "a repo sync is already running".into(),
+        ));
+    }
+    let config = app.config.clone();
+    let roots = crate::recipe_repos::sync_recipe_repos(&config).await;
+    SYNCING.store(false, std::sync::atomic::Ordering::SeqCst);
+    Ok(Json(
+        serde_json::json!({ "ok": true, "repos": roots.len() }),
+    ))
+}
+
 // ---------------------------------------------------------------------------
 // P3: log windows — attach a streaming `docker logs -f` per node+recipe+rank
 // (idempotent), detach, and fetch ring history for a fresh window.

+ 17 - 0
src/web/api_tests.rs

@@ -35,6 +35,23 @@ async fn recipes_endpoint_lists_repo_recipes() {
     assert!(v["recipes"].is_array());
 }
 
+/// The recipes panel's ⟳ button: a sync pass over the configured repos
+/// (none in the fake config → 0 roots, still ok) and a 409-free single
+/// in-flight guard.
+#[tokio::test]
+async fn repos_sync_endpoint_reports_repo_count() {
+    let (addr, _h) = boot().await;
+    let res = reqwest::Client::new()
+        .post(format!("http://{addr}/api/repos/sync"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::OK);
+    let v: serde_json::Value = res.json().await.unwrap();
+    assert_eq!(v["ok"], true);
+    assert!(v["repos"].is_number(), "repos count missing: {v}");
+}
+
 #[tokio::test]
 async fn serve_endpoint_registers_job_and_rejects_concurrent() {
     let (addr, _h) = boot().await;

+ 1 - 0
src/web/mod.rs

@@ -262,6 +262,7 @@ pub fn router(app: AppState) -> Router {
         .route("/api/stop", post(api::stop))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))
+        .route("/api/repos/sync", post(api::repos_sync))
         .route("/api/login", post(login))
         .route(
             "/api/logs",

+ 10 - 0
src/web/ui.html

@@ -780,6 +780,7 @@ function renderDock() {
   // New-window menu (plan): State / Logs / Jobs / Raw.
   const plus = document.createElement("button");
   plus.className = "dock-chip plus";
+  plus.id = "dockplus";
   plus.textContent = "+";
   plus.title = "new window";
   plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
@@ -806,6 +807,15 @@ function toggleDockMenu() {
   item("logs…", openLogsPicker);
   item("raw", openRaw);
   document.body.appendChild(m);
+  // Anchor the menu to the + button that opened it (the chips flow left,
+  // so the button sits at the dock's left edge) — a fixed bottom-right menu
+  // made the click-to-menu mouse travel cross the whole screen.
+  const plusEl = document.getElementById("dockplus");
+  if (plusEl) {
+    const rc = plusEl.getBoundingClientRect();
+    m.style.left = Math.max(8, rc.left) + "px";
+    m.style.right = "auto";
+  }
   dockMenuEl = m;
   setTimeout(() => addEventListener("click", closeDockMenu, { once: true }), 0);
 }