Parcourir la source

web P4: raw content windows + animation polish

Raw windows (one window, kind selector — config / effective / recipes):
- GET /api/raw/config — the active config file verbatim
- GET /api/raw/recipe/{name} — recipe.toml verbatim (search order as
  load_recipe; traversal names 400)
- GET /api/raw/effective — merged Config as JSON post cluster-source
  merge, HA token redacted
- monospace pre + copy button; effective pretty-printed; per-recipe
  entries; dock entry '+ raw config / recipe / effective'

Polish (CSS-only):
- skeleton shimmer in the state window until the first snapshot renders
- amber attention pulse: failed (Exited) container LEDs, busy action
  buttons (job in flight), running job dots
- all new animations respect prefers-reduced-motion

Tests: raw endpoints round-trip (effective JSON shape, recipe verbatim,
404 unknown, 400 traversal); Playwright +4 checks (31 total) — raw
window from dock, effective JSON, config verbatim, skeleton cleared.
Negative-verified (dock chip removed -> click times out).
Alexander Korolev il y a 2 mois
Parent
commit
3ca1d59e4e
5 fichiers modifiés avec 280 ajouts et 5 suppressions
  1. 94 0
      src/web/api.rs
  2. 3 0
      src/web/mod.rs
  3. 75 0
      src/web/tests.rs
  4. 85 3
      src/web/ui.html
  5. 23 2
      src/web/ui.playwright.mjs

+ 94 - 0
src/web/api.rs

@@ -291,3 +291,97 @@ pub(crate) async fn logs_history(
         serde_json::json!({ "attach_id": id, "history": history }),
     ))
 }
+
+// ---------------------------------------------------------------------------
+// P4: raw content — the active config file, a recipe verbatim, and the
+// merged effective config (post cluster-source merge) as JSON.
+
+/// `GET /api/raw/config` — the active config file, verbatim.
+pub(crate) async fn raw_config() -> Result<axum::response::Response, ApiError> {
+    let path = crate::gconfig::resolve_config_path("config.toml");
+    let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
+        (
+            StatusCode::NOT_FOUND,
+            format!("no config file at {}: {e}", path.display()),
+        )
+    })?;
+    Ok(axum::response::Response::builder()
+        .status(200)
+        .header("content-type", "text/plain; charset=utf-8")
+        .body(axum::body::Body::from(content))
+        .unwrap())
+}
+
+/// `GET /api/raw/recipe/<name>` — recipe.toml verbatim (search order as
+/// `load_recipe`: cwd, recipe_sources, global dir).
+pub(crate) async fn raw_recipe(
+    app: State<AppState>,
+    axum::extract::Path(name): axum::extract::Path<String>,
+) -> Result<axum::response::Response, ApiError> {
+    if name.is_empty() || name.contains('/') || name.contains("..") {
+        return Err((StatusCode::BAD_REQUEST, "invalid recipe name".to_string()));
+    }
+    let dirs = crate::config::recipe_dirs(&app.config);
+    for dir in &dirs {
+        let p = dir.join(&name).join("recipe.toml");
+        if let Ok(content) = tokio::fs::read_to_string(&p).await {
+            return Ok(axum::response::Response::builder()
+                .status(200)
+                .header("content-type", "text/plain; charset=utf-8")
+                .body(axum::body::Body::from(content))
+                .unwrap());
+        }
+    }
+    Err((StatusCode::NOT_FOUND, format!("recipe '{name}' not found")))
+}
+
+/// `GET /api/raw/effective` — the merged `Config` as JSON (post
+/// cluster-source merge), HA token redacted.
+pub(crate) async fn raw_effective(app: State<AppState>) -> Json<serde_json::Value> {
+    use crate::config::NcclConfig;
+    let ha = app.config.home_assistant.as_ref().map(|h| {
+        let masked: String = h.token.chars().take(12).collect();
+        let token = if h.token.len() > 12 {
+            format!("{masked}…<redacted>")
+        } else {
+            "<redacted>".to_string()
+        };
+        serde_json::json!({
+            "url": h.url,
+            "token": token,
+            "shutdown_delay_seconds": h.shutdown_delay_seconds,
+        })
+    });
+    Json(serde_json::json!({
+        "config_path": crate::gconfig::resolve_config_path("config.toml").display().to_string(),
+        "ssh_user": app.config.ssh_user,
+        "ssh_key_path": app.config.ssh_key_path.display().to_string(),
+        "clusters": app.config.clusters.iter().map(|c| serde_json::json!({
+            "name": c.name,
+            "nodes": c.nodes.iter().map(|n| serde_json::json!({
+                "name": n.name,
+                "ssh_ip": n.ssh_ip,
+                "rank": n.rank,
+                "entity": n.entity,
+                "host_key_set": n.host_key.is_some(),
+            })).collect::<Vec<_>>(),
+            "extra_entities": c.extra_entities.iter().map(|e| serde_json::json!({
+                "entity": e.entity(),
+                "blocks": e.blocks(),
+            })).collect::<Vec<_>>(),
+            "nccl": app.config.nccl.get(&c.name).map(|n: &NcclConfig| serde_json::json!({
+                "ib_hca": n.ib_hca,
+                "socket_ifname": n.socket_ifname,
+                "ib_gid_index": n.ib_gid_index,
+                "ib_disable": n.ib_disable,
+            })),
+            "before_serve": app.config.before_serve.get(&c.name).map(|b| b.commands.len()),
+        })).collect::<Vec<_>>(),
+        "home_assistant": ha,
+        "init_port": app.config.init_port,
+        "mount_aliases": app.config.mount_aliases,
+        "recipe_sources": app.config.recipe_sources,
+        "cluster_sources": app.config.cluster_sources,
+        "drop_caches": app.config.drop_caches,
+    }))
+}

+ 3 - 0
src/web/mod.rs

@@ -156,6 +156,9 @@ pub fn router(app: AppState) -> Router {
                 .post(api::logs_attach)
                 .delete(api::logs_detach),
         )
+        .route("/api/raw/config", get(api::raw_config))
+        .route("/api/raw/recipe/{name}", get(api::raw_recipe))
+        .route("/api/raw/effective", get(api::raw_effective))
         .route("/events", get(events_stream))
         .layer(middleware::from_fn_with_state(app.clone(), token_guard));
     Router::new()

+ 75 - 0
src/web/tests.rs

@@ -450,3 +450,78 @@ async fn log_event_serializes_with_type_tag() {
     assert_eq!(json["attach"], "l1");
     assert_eq!(json["line"], "hello");
 }
+
+// --- P4: raw content -------------------------------------------------------------
+
+#[tokio::test]
+async fn raw_endpoints_serve_config_recipe_effective() {
+    let (addr, _h) = boot().await;
+    let base = format!("http://{addr}");
+    let client = reqwest::Client::new();
+
+    // The active config file verbatim (cwd `config.toml` in tests).
+    let res = client
+        .get(format!("{base}/api/raw/config"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200, "repo config.toml readable");
+    assert!(
+        res.text().await.unwrap().contains("ssh_user"),
+        "config verbatim"
+    );
+
+    // Effective: merged config as JSON; the fake cluster is present and the
+    // (absent) HA token never leaks.
+    let res = client
+        .get(format!("{base}/api/raw/effective"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200);
+    let body: serde_json::Value = res.json().await.unwrap();
+    let names: Vec<&str> = body["clusters"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|c| c["name"].as_str().unwrap())
+        .collect();
+    assert!(names.contains(&"fake"), "{names:?}");
+    assert_eq!(body["ssh_user"], "u");
+    assert!(
+        body.get("home_assistant")
+            .map(|v| v.is_null())
+            .unwrap_or(true)
+    );
+
+    // Recipe: a real recipe from the repo (tests run from the crate root).
+    let res = client
+        .get(format!("{base}/api/raw/recipe/deepseek-v4-flash"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200, "repo recipe readable");
+    let text = res.text().await.unwrap();
+    assert!(text.contains("engine"), "recipe.toml verbatim");
+
+    // Unknown recipe 404s; path traversal is refused before any file access.
+    assert_eq!(
+        client
+            .get(format!("{base}/api/raw/recipe/no-such-recipe"))
+            .send()
+            .await
+            .unwrap()
+            .status(),
+        404
+    );
+    assert_eq!(
+        client
+            .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc"))
+            .send()
+            .await
+            .unwrap()
+            .status(),
+        400,
+        "traversal name refused"
+    );
+}

+ 85 - 3
src/web/ui.html

@@ -188,6 +188,28 @@
     word-break: break-word; color: var(--fg);
   }
 
+  /* --- raw content windows --------------------------------------------------- */
+  .rawwin .win-body { display: flex; flex-direction: column; padding: 0; }
+  .rawbar {
+    display: flex; gap: 8px; align-items: center; padding: 8px 10px;
+    border-bottom: 1px solid var(--border); flex-shrink: 0;
+  }
+  .rawbar select {
+    background: var(--btn); color: var(--fg); border: 1px solid var(--border);
+    border-radius: 6px; padding: 2px 6px; font-size: 12px; max-width: 320px; height: 26px;
+  }
+  .rawbar .abtn { height: 26px; }
+  .rawnote { font-size: 12px; color: var(--muted); flex: 1; }
+
+  /* --- polish: skeleton + attention ------------------------------------------ */
+  .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
+    background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
+    background-size: 200% 100%; animation: shimmer 1.2s infinite; }
+  @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
+  .cled.exited { background: var(--led-off); box-shadow: 0 0 8px 2px rgba(248,81,73,.5); animation: pulse 1.2s infinite; }
+  .abtn:disabled { animation: pulse 1.2s infinite; }
+  .job.jjob .jdot { animation: pulse 1.2s infinite; }
+
   /* --- confirm dialog ------------------------------------------------------- */
   .dlg-back[hidden] { display: none; }
   .dlg-back {
@@ -214,7 +236,7 @@
   @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
   @keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
   @media (prefers-reduced-motion: reduce) {
-    *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win { animation: none !important; }
+    *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
   }
 </style>
 </head>
@@ -346,6 +368,7 @@ function renderDock() {
   }
   for (const w of logWins.values()) {
     if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
+  if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
   }
 }
 
@@ -413,7 +436,8 @@ function renderCluster(c, prev, animate) {
       for (const x of n.containers) {
         const crow = document.createElement("div");
         crow.className = "crow";
-        crow.innerHTML = `<span class="cled"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
+        const dead = /exited|dead/i.test(x.status);
+        crow.innerHTML = `<span class="cled${dead ? " exited" : ""}"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
         box.appendChild(crow);
       }
       el.appendChild(box);
@@ -692,6 +716,63 @@ function detachLog(attachId, hide = true) {
   if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
 }
 
+// --- raw content windows ---------------------------------------------------------
+// One window, a kind selector: config / effective / recipe:<name>. Content is
+// verbatim from the server (effective is pretty JSON); copy grabs the text.
+let rawWin = null;
+async function openRaw() {
+  if (rawWin) { front(rawWin); return; }
+  const handle = makeWin({
+    title: "raw", x: 20, y: 20, w: Math.min(680, innerWidth - 40), h: Math.min(460, innerHeight * 0.65),
+    bodyHtml: `<div class="rawbar">
+      <select></select>
+      <button class="abtn rawcopy">copy</button>
+      <span class="rawnote"></span>
+    </div><pre class="logpre rawpre"></pre>`,
+    extraCls: "rawwin",
+    removeOnClose: true,
+    onClosed: () => { rawWin = null; },
+  });
+  rawWin = handle.win;
+  const sel = handle.body.querySelector("select");
+  const pre = handle.body.querySelector(".rawpre");
+  const note = handle.body.querySelector(".rawnote");
+  handle.body.querySelector(".rawcopy").onclick = async () => {
+    try { await navigator.clipboard.writeText(pre.textContent); } catch {}
+  };
+  const fill = () => {
+    if (!state.recipes.length) return;
+    const cur = sel.value;
+    sel.innerHTML = "";
+    for (const kind of ["config", "effective", "recipe:", ...state.recipes.map((r) => "recipe:" + r.name)]) {
+      const o = document.createElement("option");
+      o.value = kind === "recipe:" ? "" : kind;
+      o.textContent = kind === "effective" ? "effective (merged config)" : kind;
+      if (kind === "recipe:") { o.disabled = true; o.textContent = "— recipes —"; }
+      sel.appendChild(o);
+    }
+    sel.value = cur || "config";
+    load();
+  };
+  async function load() {
+    const kind = sel.value;
+    note.textContent = "";
+    if (!kind) return;
+    const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
+    try {
+      const res = await fetch(path);
+      const text = await res.text();
+      if (!res.ok) { note.textContent = "error: " + text; return; }
+      if (kind === "effective") {
+        try { pre.textContent = JSON.stringify(JSON.parse(text), null, 2); } catch { pre.textContent = text; }
+      } else pre.textContent = text;
+    } catch (e) { note.textContent = "error: " + e.message; }
+  }
+  sel.onchange = load;
+  if (state.recipes.length) fill();
+  else state.onRecipesReady = fill; // recipes may arrive after the first open
+}
+
 // --- actions -----------------------------------------------------------------------
 const cur = () => state.snap;
 function recipeOf(cluster) {
@@ -836,7 +917,7 @@ function initManagedWindows() {
   wins.state = makeWin({
     id: "win-state", title: "Cluster state", x: 12, y: 12,
     w: Math.max(430, Math.floor(W * 0.52)), h: Math.max(320, Math.floor(H * 0.78)),
-    bodyHtml: `<div id="clusters"></div>`,
+    bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
   });
   wins.jobs = makeWin({
     id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
@@ -867,6 +948,7 @@ async function boot() {
       const d = await res.json();
       state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
       if (state.snap) onState(state.snap); // re-render with recipes
+      if (state.onRecipesReady) { const f = state.onRecipesReady; delete state.onRecipesReady; f(); }
     }
   } catch {}
   connectSSE();

+ 23 - 2
src/web/ui.playwright.mjs

@@ -158,9 +158,9 @@ await page.click("#win-state .wbtn[data-act=min]");
 ok(
   "minimize hides the window and adds a dock chip",
   !(await page.locator("#win-state").isVisible()) &&
-    (await page.locator("#dock .dock-chip").count()) === 1,
+    (await page.locator("#dock .dock-chip:has-text('Cluster state')").count()) === 1,
 );
-await page.click("#dock .dock-chip");
+await page.click("#dock .dock-chip:has-text('Cluster state')");
 ok(
   "dock chip restores the window",
   await page.locator("#win-state").isVisible(),
@@ -200,6 +200,27 @@ ok(
     (await page.locator(".logwin .lognote").textContent()).includes("no sm121 containers"),
 );
 
+// --- 13. raw window ------------------------------------------------------------
+await page.click('#dock .dock-chip:has-text("raw")');
+await page.waitForSelector(".rawwin", { timeout: 10000 });
+ok("raw window opens from the dock", (await page.locator(".rawwin").count()) === 1);
+await page.selectOption(".rawwin select", "effective");
+await page.waitForFunction(
+  () => (document.querySelector(".rawwin .rawpre").textContent || "").includes('"clusters"'),
+  { timeout: 5000 },
+);
+ok("raw: effective shows the merged config JSON", true);
+await page.selectOption(".rawwin select", "config");
+await page.waitForFunction(
+  () => (document.querySelector(".rawwin .rawpre").textContent || "").includes("ssh_user"),
+  { timeout: 5000 },
+);
+ok("raw: config shows the file verbatim", true);
+ok(
+  "skeleton shimmer cleared after first state",
+  (await page.locator(".skeleton").count()) === 0,
+);
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));