Răsfoiți Sursa

web UI: aligned action bar, sorted recipes, active-cluster select

- uniform 26px control heights in the action bar (select/checkbox/
  buttons were drifting off each other)
- recipes sorted by name
- only the active cluster renders its full node/action panel; all
  clusters stay visible as a compact LED chip strip — click a chip to
  switch (persisted in localStorage)
- jobs empty state explains what the window is for (actions started
  from this dashboard)
Alexander Korolev 2 luni în urmă
părinte
comite
1c0e3da392
1 a modificat fișierele cu 36 adăugiri și 9 ștergeri
  1. 36 9
      src/web/ui.html

+ 36 - 9
src/web/ui.html

@@ -72,13 +72,22 @@
   .actions select, .actions input[type=checkbox] { font: inherit; }
   .actions select {
     background: var(--btn); color: var(--fg); border: 1px solid var(--border);
-    border-radius: 6px; padding: 3px 6px; font-size: 12.5px; max-width: 260px;
+    border-radius: 6px; padding: 2px 6px; font-size: 12.5px; max-width: 260px; height: 26px;
   }
-  .actions label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 4px; }
+  .actions label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; height: 26px; }
+  .actions label input { margin: 0; }
+  .strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
+  .chip {
+    display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;
+    border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font-size: 12px;
+    color: var(--muted); background: var(--btn); transition: border-color .15s, color .15s;
+  }
+  .chip .m { opacity: .7; }
+  .chip.active { color: var(--fg); border-color: var(--accent); }
   .abtn {
     background: var(--btn); color: var(--fg); border: 1px solid var(--border);
-    border-radius: 6px; padding: 3px 12px; cursor: pointer; font: inherit; font-size: 12.5px;
-    transition: background .15s;
+    border-radius: 6px; padding: 0 12px; cursor: pointer; font: inherit; font-size: 12.5px;
+    height: 26px; transition: background .15s;
   }
   .abtn:hover:not(:disabled) { background: var(--btn-hover); }
   .abtn:disabled { opacity: .45; cursor: default; }
@@ -132,7 +141,7 @@
 </header>
 <main>
   <section class="card" id="state-card"><h2>Cluster state</h2><div id="clusters"></div></section>
-  <section class="card"><h2>Jobs</h2><div class="jobs" id="jobs"><div class="jobs-empty">no jobs yet</div></div></section>
+  <section class="card"><h2>Jobs</h2><div class="jobs" id="jobs"><div class="jobs-empty">no actions yet — serve / stop / power started from this dashboard appear here</div></div></section>
 </main>
 <div class="dlg-back" id="dlg" hidden>
   <div class="dlg">
@@ -148,7 +157,7 @@
 "use strict";
 const $ = (s) => document.querySelector(s);
 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
-const state = { snap: null, jobs: new Map(), recipes: [] };
+const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "" };
 
 // --- theme ---------------------------------------------------------------
 const root = document.documentElement;
@@ -248,9 +257,27 @@ function onState(snap) {
   const prev = state.snap;
   state.snap = snap;
   state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
+  if (!snap.clusters.find((c) => c.name === state.active)) {
+    state.active = snap.clusters[0] ? snap.clusters[0].name : "";
+  }
   const box = $("#clusters");
   box.innerHTML = "";
-  for (const c of snap.clusters) box.appendChild(renderCluster(c, prev ? prev.clusters.find((p) => p.name === c.name) : null));
+  const active = snap.clusters.find((c) => c.name === state.active);
+  if (active) {
+    box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === active.name) : null));
+  }
+  // compact fleet strip: one chip per cluster, click to make it active
+  const strip = document.createElement("div");
+  strip.className = "strip";
+  for (const c of snap.clusters) {
+    const chip = document.createElement("div");
+    chip.className = "chip" + (c.name === state.active ? " active" : "");
+    const dot = c.free > 0 ? "on" : c.nodes.some((n) => n.occupied) ? "b" : "off";
+    chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${c.free} free</span>`;
+    chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap); };
+    strip.appendChild(chip);
+  }
+  box.appendChild(strip);
   renderJobs();
 }
 
@@ -259,7 +286,7 @@ function renderJobs() {
   const box = $("#jobs");
   box.innerHTML = "";
   const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
-  if (!js.length) { box.innerHTML = '<div class="jobs-empty">no jobs yet</div>'; return; }
+  if (!js.length) { box.innerHTML = '<div class="jobs-empty">no actions yet — serve / stop / power started from this dashboard appear here</div>'; return; }
   for (const j of js) {
     const el = document.createElement("div");
     el.className = "job " + (j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob");
@@ -441,7 +468,7 @@ async function boot() {
     const res = await fetch("/api/recipes");
     if (res.ok) {
       const d = await res.json();
-      state.recipes = d.recipes || [];
+      state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
       if (state.snap) onState(state.snap); // re-render with recipes
     }
   } catch {}