|
@@ -279,6 +279,62 @@
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
*, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
|
|
*, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /* --- topology map ------------------------------------------------------- */
|
|
|
|
|
+ #topo { height: 100%; overflow: hidden; }
|
|
|
|
|
+ #topo svg { width: 100%; height: 100%; display: block; }
|
|
|
|
|
+ #topo .topo-empty { height: 100%; display: grid; place-items: center; color: var(--muted); }
|
|
|
|
|
+ #topo text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
|
|
|
|
|
+ #topo .sub { fill: var(--muted); font-size: 10px; }
|
|
|
|
|
+ #topo .dev { transition: opacity .5s; }
|
|
|
|
|
+ #topo .dev.unpowered { opacity: .32; }
|
|
|
|
|
+ #topo .dev .body { fill: color-mix(in srgb, var(--panel) 72%, var(--border)); stroke: var(--border); stroke-width: 1.2; }
|
|
|
|
|
+ #topo .dev .top { fill: color-mix(in srgb, var(--fg) 10%, transparent); }
|
|
|
|
|
+ #topo .dev .vent { fill: color-mix(in srgb, var(--muted) 55%, transparent); }
|
|
|
|
|
+ #topo .dev .plate { fill: color-mix(in srgb, var(--bg) 45%, var(--panel)); }
|
|
|
|
|
+ #topo .dev .nm { font-size: 12.5px; font-weight: 600; }
|
|
|
|
|
+ #topo .dev .ip { font-size: 10px; fill: var(--muted); font-family: var(--mono); }
|
|
|
|
|
+ #topo .dev .ctr { fill: color-mix(in srgb, var(--led-free) 65%, transparent); stroke: var(--border); }
|
|
|
|
|
+ #topo .dev .ctr.hot { fill: color-mix(in srgb, var(--led-busy) 30%, transparent); }
|
|
|
|
|
+ #topo .dev .ctrt { font-size: 8.5px; fill: var(--muted); }
|
|
|
|
|
+ #topo .dev.planned .body { stroke-dasharray: 4 4; opacity: .5; }
|
|
|
|
|
+ .pled { fill: var(--led-free); }
|
|
|
|
|
+ .pled.on { fill: var(--led-on); filter: drop-shadow(0 0 3px var(--led-on)); }
|
|
|
|
|
+ .pled.b { fill: var(--led-busy); filter: drop-shadow(0 0 3px var(--led-busy)); animation: pulse 1.6s infinite; }
|
|
|
|
|
+ .pled.off { fill: var(--led-off); }
|
|
|
|
|
+ #topo .cable { fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
|
|
|
|
+ #topo .c-200g { stroke: color-mix(in srgb, var(--accent) 60%, transparent); stroke-width: 2.6; }
|
|
|
|
|
+ #topo .c-dac { stroke: color-mix(in srgb, #a371f7 60%, transparent); stroke-width: 2.6; }
|
|
|
|
|
+ #topo .c-10g { stroke: color-mix(in srgb, var(--muted) 50%, transparent); stroke-width: 1.4; }
|
|
|
|
|
+ #topo .flow { opacity: 0; transition: opacity .6s; }
|
|
|
|
|
+ #topo .flow.live { opacity: .9; }
|
|
|
|
|
+ #topo .flow.c-200g { stroke: var(--accent); stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
|
|
|
|
|
+ #topo .flow.c-dac { stroke: #a371f7; stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
|
|
|
|
|
+ #topo .flow.c-10g { stroke: color-mix(in srgb, var(--fg) 50%, transparent); stroke-width: 1.5; stroke-dasharray: 3 9; animation: flow 1.6s linear infinite; }
|
|
|
|
|
+ @keyframes flow { to { stroke-dashoffset: -14; } }
|
|
|
|
|
+ #topo .port { fill: var(--led-free); }
|
|
|
|
|
+ #topo .port.on { fill: var(--led-on); }
|
|
|
|
|
+ #topo .swbox { fill: var(--panel); stroke: var(--border); stroke-width: 1.4; }
|
|
|
|
|
+ #topo .sw .swbox { transition: stroke .4s; }
|
|
|
|
|
+ #topo .sw.ib.on .swbox { stroke: color-mix(in srgb, var(--led-on) 65%, transparent); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--led-on) 35%, transparent)); }
|
|
|
|
|
+ #topo .sw.ib.off .swbox { stroke: color-mix(in srgb, var(--led-off) 75%, transparent); }
|
|
|
|
|
+ #topo .sw.ib.unk .swbox { stroke: color-mix(in srgb, var(--led-busy) 60%, transparent); }
|
|
|
|
|
+ #topo .fan .fbody { fill: var(--panel); stroke: var(--border); stroke-width: 1.4; }
|
|
|
|
|
+ #topo .fan .blade { fill: color-mix(in srgb, var(--muted) 75%, transparent); }
|
|
|
|
|
+ #topo .fan .hub { fill: var(--border); }
|
|
|
|
|
+ #topo .fan .blades { transform-box: fill-box; transform-origin: center; animation: spin 1.4s linear infinite; }
|
|
|
|
|
+ #topo .fan.off .blades { animation: none; opacity: .4; }
|
|
|
|
|
+ #topo .fan.unk .blades { animation: spin 4s linear infinite; opacity: .55; }
|
|
|
|
|
+ @keyframes spin { to { transform: rotate(1turn); } }
|
|
|
|
|
+ #topo .cluster-frame { cursor: pointer; }
|
|
|
|
|
+ #topo .cluster-frame:hover .frame { stroke-width: 2.2; }
|
|
|
|
|
+ #topo .frame { fill: color-mix(in srgb, var(--led-on) 5%, transparent); stroke: color-mix(in srgb, var(--led-on) 40%, transparent); stroke-width: 1.5; transition: stroke .4s, fill .4s; }
|
|
|
|
|
+ #topo .cluster-frame.down .frame { fill: color-mix(in srgb, var(--led-off) 5%, transparent); stroke: color-mix(in srgb, var(--led-off) 45%, transparent); }
|
|
|
|
|
+ #topo .fchip rect { fill: var(--panel); stroke: var(--border); }
|
|
|
|
|
+ #topo .fchip text { font-size: 10.5px; fill: var(--muted); letter-spacing: .3px; }
|
|
|
|
|
+ #topo .fchip .cdot { fill: var(--led-off); }
|
|
|
|
|
+ #topo .fchip .cdot.on { fill: var(--led-on); }
|
|
|
|
|
+ #topo .legend text { font-size: 10.5px; fill: var(--muted); }
|
|
|
</style>
|
|
</style>
|
|
|
</head>
|
|
</head>
|
|
|
<body>
|
|
<body>
|
|
@@ -457,6 +513,7 @@ function toggleDockMenu() {
|
|
|
b.onclick = () => { closeDockMenu(); fn(); };
|
|
b.onclick = () => { closeDockMenu(); fn(); };
|
|
|
m.appendChild(b);
|
|
m.appendChild(b);
|
|
|
};
|
|
};
|
|
|
|
|
+ item("topo", () => { (wins.topo.isMinimized() ? wins.topo.restore() : front(wins.topo.win)); });
|
|
|
item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
|
|
item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
|
|
|
item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
|
|
item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
|
|
|
item("logs…", openLogsPicker);
|
|
item("logs…", openLogsPicker);
|
|
@@ -646,6 +703,7 @@ function onState(snap, force) {
|
|
|
}
|
|
}
|
|
|
state.snap = snap;
|
|
state.snap = snap;
|
|
|
state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
|
|
state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
|
|
|
|
|
+ renderTopo(snap);
|
|
|
if (!snap.clusters.find((c) => c.name === state.active)) {
|
|
if (!snap.clusters.find((c) => c.name === state.active)) {
|
|
|
state.active = snap.clusters[0] ? snap.clusters[0].name : "";
|
|
state.active = snap.clusters[0] ? snap.clusters[0].name : "";
|
|
|
}
|
|
}
|
|
@@ -688,6 +746,264 @@ function restoreControls(name, recipe, dry) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// --- topology map -------------------------------------------------------------
|
|
|
|
|
+// The physical layer behind the windows: 8 GX10 boxes on a 2×4 grid, the
|
|
|
|
|
+// 200G fabric switch and the passive 10G switch in the middle corridor,
|
|
|
|
|
+// DAC direct links, HA glyphs (fan) and cluster frames. The structure is
|
|
|
|
|
+// rebuilt only when the topo signature changes (config edit); state ticks
|
|
|
|
|
+// just flip classes so flow/pulse/spin animations keep running.
|
|
|
|
|
+const TG_W = 1060, TG_H = 516;
|
|
|
|
|
+const tgPos = (i) => {
|
|
|
|
|
+ const cx = 20 + (i % 4) * 255;
|
|
|
|
|
+ return { x: cx + 37, y: i < 4 ? 30 : 370, w: 180, h: 104, dcx: cx + 127 };
|
|
|
|
|
+};
|
|
|
|
|
+const tgSlot = (n) => {
|
|
|
|
|
+ const m = String(n).match(/\d+/);
|
|
|
|
|
+ return m ? +m[0] : 999;
|
|
|
|
|
+};
|
|
|
|
|
+const tgIdx = (t, name) => t.nodes.findIndex((n) => n === name);
|
|
|
|
|
+
|
|
|
|
|
+function topoSig(t) {
|
|
|
|
|
+ return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
|
|
|
|
|
+ + "|" + t.switches.map((s) => s.entity).join(",");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tgCablePath(l, t) {
|
|
|
|
|
+ const bi = tgIdx(t, l.b);
|
|
|
|
|
+ if (bi < 0) return "";
|
|
|
|
|
+ const b = tgPos(bi);
|
|
|
|
|
+ if (l.kind === "10g") {
|
|
|
|
|
+ const px = 396 + bi * 38;
|
|
|
|
|
+ if (bi < 4) { // row0: device bottom → 10G switch top
|
|
|
|
|
+ const ex = b.dcx + 40, ey = b.y + b.h;
|
|
|
|
|
+ return `M ${ex} ${ey} C ${ex} ${ey + 26} ${px} ${ey + 6} ${px} 170`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 226`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (l.kind === "200g") { // row0: device bottom → fabric switch top
|
|
|
|
|
+ const ex = b.dcx - 40, ey = b.y + b.h;
|
|
|
|
|
+ const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
|
|
|
|
|
+ const px = 396 + fi * 80;
|
|
|
|
|
+ return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 250`;
|
|
|
|
|
+ }
|
|
|
|
|
+ // dac: arc between adjacent device tops
|
|
|
|
|
+ const ai = tgIdx(t, l.a);
|
|
|
|
|
+ if (ai < 0) return "";
|
|
|
|
|
+ const a = tgPos(ai);
|
|
|
|
|
+ const mx = (a.x + a.w + b.x) / 2;
|
|
|
|
|
+ return `M ${a.x + a.w - 4} ${a.y + 14} C ${mx} ${a.y - 22} ${mx} ${a.y - 22} ${b.x + 4} ${b.y + 14}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function devSvg(name, i) {
|
|
|
|
|
+ const d = tgPos(i);
|
|
|
|
|
+ return `<g class="dev" data-dev="${esc(name)}" transform="translate(${d.x},${d.y})">
|
|
|
|
|
+ <title>${esc(name)}</title>
|
|
|
|
|
+ <rect class="body" width="${d.w}" height="${d.h}" rx="10"/>
|
|
|
|
|
+ <rect class="top" x="4" y="3" width="${d.w - 8}" height="7" rx="3.5"/>
|
|
|
|
|
+ <rect class="vent" x="14" y="24" width="5" height="24" rx="2.5"/>
|
|
|
|
|
+ <rect class="vent" x="23" y="24" width="5" height="24" rx="2.5"/>
|
|
|
|
|
+ <rect class="vent" x="32" y="24" width="5" height="24" rx="2.5"/>
|
|
|
|
|
+ <rect class="ctr" x="122" y="18" width="52" height="15" rx="7.5"/>
|
|
|
|
|
+ <text class="ctrt" x="148" y="29" text-anchor="middle">…</text>
|
|
|
|
|
+ <rect class="plate" x="1" y="62" width="${d.w - 2}" height="41" rx="8"/>
|
|
|
|
|
+ <text class="nm" x="12" y="79">${esc(name)}</text>
|
|
|
|
|
+ <text class="ip" x="12" y="94">…</text>
|
|
|
|
|
+ <circle class="pled" cx="166" cy="74" r="4"/>
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function swSvg(sw, entries) {
|
|
|
|
|
+ const label = sw.kind === "lan" ? "10G switch"
|
|
|
|
|
+ : sw.entity.includes(".") ? sw.entity.split(".")[1] : sw.entity;
|
|
|
|
|
+ const ib = sw.kind === "ib";
|
|
|
|
|
+ const y = ib ? 250 : 170;
|
|
|
|
|
+ const sub = ib ? "200G fabric · HA" : "passive · no HA";
|
|
|
|
|
+ const step = ib ? 80 : 38;
|
|
|
|
|
+ const rows = entries.map((p) => `<rect class="port" data-${ib ? "fab" : "lan"}port="${esc(p.name)}"
|
|
|
|
|
+ x="${396 + p.i * step - 7}" y="${p.top ? y + 2 : y + 49}" width="14" height="7" rx="2"/>`).join("");
|
|
|
|
|
+ return `<g class="sw ${sw.kind}" data-sw="${esc(sw.entity)}" transform="translate(0,0)">
|
|
|
|
|
+ <title>${esc(sw.entity)}</title>
|
|
|
|
|
+ <rect class="swbox" x="370" y="${y}" width="320" height="56" rx="8"/>
|
|
|
|
|
+ ${rows}
|
|
|
|
|
+ <text x="386" y="${y + 32}">${esc(label)}</text>
|
|
|
|
|
+ <text class="sub" x="386" y="${y + 46}">${sub}</text>
|
|
|
|
|
+ ${ib ? '<circle class="pled swled" cx="672" cy="' + (y + 40) + '" r="4"/>' : ""}
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fanSvg(sw) {
|
|
|
|
|
+ return `<g class="fan" data-sw="${esc(sw.entity)}">
|
|
|
|
|
+ <title>${esc(sw.entity)}</title>
|
|
|
|
|
+ <circle class="fbody" cx="770" cy="278" r="26"/>
|
|
|
|
|
+ <g class="blades">
|
|
|
|
|
+ <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z"/>
|
|
|
|
|
+ <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z" transform="rotate(120 770 278)"/>
|
|
|
|
|
+ <path class="blade" d="M770 254 C 781 258 783 270 775 276 L 770 278 Z" transform="rotate(240 770 278)"/>
|
|
|
|
|
+ <circle class="hub" cx="770" cy="278" r="5"/>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ <text class="sub" x="770" y="322" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function topoFrames(snap) {
|
|
|
|
|
+ const t = snap.topo;
|
|
|
|
|
+ const rects = [];
|
|
|
|
|
+ for (const c of snap.clusters) {
|
|
|
|
|
+ const ps = c.nodes.map((n) => tgPos(tgIdx(t, n.name))).filter(Boolean);
|
|
|
|
|
+ if (!ps.length) continue;
|
|
|
|
|
+ const x = Math.min(...ps.map((p) => p.x)) - 14;
|
|
|
|
|
+ const y = Math.min(...ps.map((p) => p.y)) - 24;
|
|
|
|
|
+ const w = Math.max(...ps.map((p) => p.x + p.w)) + 14 - x;
|
|
|
|
|
+ const h = Math.max(...ps.map((p) => p.y + p.h)) + 14 - y;
|
|
|
|
|
+ rects.push({ name: c.name, x, y, w, h, area: w * h });
|
|
|
|
|
+ }
|
|
|
|
|
+ rects.sort((a, b) => b.area - a.area); // big frames behind, nested on top
|
|
|
|
|
+ const depthCount = {};
|
|
|
|
|
+ let out = "";
|
|
|
|
|
+ for (const r of rects) {
|
|
|
|
|
+ const depth = rects.filter((o) => o !== r && o !== undefined && o.x <= r.x && o.y <= r.y
|
|
|
|
|
+ && o.x + o.w >= r.x && o.y + o.h >= r.y).length;
|
|
|
|
|
+ const n = (depthCount[depth] = (depthCount[depth] || 0) + 1);
|
|
|
|
|
+ const chipX = r.x + 10 + depth * 20 + (n - 1) * 14;
|
|
|
|
|
+ const cw = r.name.length * 6.6 + 30;
|
|
|
|
|
+ out += `<g class="cluster-frame" data-cluster="${esc(r.name)}">
|
|
|
|
|
+ <title>${esc(r.name)}</title>
|
|
|
|
|
+ <rect class="frame" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14"/>
|
|
|
|
|
+ <g class="fchip" transform="translate(${chipX},${r.y - 8})">
|
|
|
|
|
+ <rect width="${cw}" height="16" rx="8"/>
|
|
|
|
|
+ <circle class="cdot" cx="10" cy="8" r="3.5"/>
|
|
|
|
|
+ <text x="18" y="12">${esc(r.name)}</text>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cablesSvg(t) {
|
|
|
|
|
+ let out = "";
|
|
|
|
|
+ for (const l of t.links) {
|
|
|
|
|
+ const p = tgCablePath(l, t);
|
|
|
|
|
+ if (!p) continue;
|
|
|
|
|
+ const key = `${l.kind}|${l.a}|${l.b}`;
|
|
|
|
|
+ out += `<path class="cable c-${l.kind}" d="${p}"/>`;
|
|
|
|
|
+ out += `<path class="cable c-${l.kind} flow" data-flow="${esc(key)}" d="${p}"/>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function switchesSvg(t) {
|
|
|
|
|
+ const fi = {};
|
|
|
|
|
+ const fabEntries = t.links.filter((l) => l.kind === "200g").map((l) => ({
|
|
|
|
|
+ name: l.b, i: (fi[l.b] = fi[l.b] ?? Object.keys(fi).length), top: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ const lanEntries = t.nodes.map((n, i) => ({ name: n, i, top: i < 4 }));
|
|
|
|
|
+ const glyphs = t.switches.map((s) => s.kind === "aux" ? fanSvg(s) : swSvg(s, fabEntries)).join("");
|
|
|
|
|
+ // the passive 10G switch has no HA entity — the UI draws it explicitly
|
|
|
|
|
+ return glyphs + swSvg({ entity: "lan", kind: "lan" }, lanEntries);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
|
|
|
|
|
+
|
|
|
|
|
+function legendSvg() {
|
|
|
|
|
+ return `<g class="legend" transform="translate(20,502)">
|
|
|
|
|
+ <line x1="0" y1="0" x2="26" y2="0" class="cable c-200g"/><text x="32" y="4">200G fabric</text>
|
|
|
|
|
+ <line x1="120" y1="0" x2="146" y2="0" class="cable c-dac"/><text x="152" y="4">DAC direct</text>
|
|
|
|
|
+ <line x1="240" y1="0" x2="266" y2="0" class="cable c-10g"/><text x="272" y="4">10G mgmt</text>
|
|
|
|
|
+ <circle cx="352" cy="0" r="4" class="pled on"/><text x="362" y="4">plug on</text>
|
|
|
|
|
+ <circle cx="428" cy="0" r="4" class="pled off"/><text x="438" y="4">plug off / HA?</text>
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Per-tick state update: flip LED/port/frame/flow classes in place —
|
|
|
|
|
+// the SVG skeleton survives, so CSS animations keep running.
|
|
|
|
|
+function topoTick(snap) {
|
|
|
|
|
+ const t = snap.topo;
|
|
|
|
|
+ const ns = {};
|
|
|
|
|
+ for (const c of snap.clusters) for (const n of c.nodes) {
|
|
|
|
|
+ const s = (ns[n.name] ||= { reachable: false, occupied: false, ha: undefined, containers: [] });
|
|
|
|
|
+ s.reachable ||= n.reachable;
|
|
|
|
|
+ s.occupied ||= n.occupied;
|
|
|
|
|
+ if (!s.ip) s.ip = n.ssh_ip;
|
|
|
|
|
+ if (n.ha !== null) s.ha = n.ha;
|
|
|
|
|
+ for (const ct of n.containers) if (!s.containers.some((x) => x.name === ct.name)) s.containers.push(ct);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of t.nodes) {
|
|
|
|
|
+ const g = document.querySelector(`#topo g[data-dev="${CSS.escape(name)}"]`);
|
|
|
|
|
+ const s = ns[name];
|
|
|
|
|
+ if (!g || !s) continue;
|
|
|
|
|
+ const led = g.querySelector(".pled");
|
|
|
|
|
+ if (led) led.setAttribute("class", "pled " + (s.reachable ? (s.occupied ? "b" : "on") : "off"));
|
|
|
|
|
+ g.classList.toggle("unpowered", s.ha === false);
|
|
|
|
|
+ const ip = g.querySelector(".ip");
|
|
|
|
|
+ if (ip) ip.textContent = s.ip || "";
|
|
|
|
|
+ const ctr = g.querySelector(".ctrt"), chip = g.querySelector(".ctr");
|
|
|
|
|
+ const recipes = [...new Set(s.containers.map((x) => x.recipe))];
|
|
|
|
|
+ if (ctr) ctr.textContent = s.containers.length ? `${s.containers.length}·${recipes[0].slice(0, 7)}` : "idle";
|
|
|
|
|
+ if (chip) chip.classList.toggle("hot", s.containers.length > 0);
|
|
|
|
|
+ const tt = g.querySelector("title");
|
|
|
|
|
+ if (tt) tt.textContent = `${name} · ${s.ip || ""} · ${s.reachable ? (s.occupied ? "serving" : "free") : "down"}`
|
|
|
|
|
+ + (s.ha === false ? " · plug OFF" : "")
|
|
|
|
|
+ + (s.containers.length ? " · " + s.containers.map((x) => x.name).join(", ") : "");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const l of t.links) {
|
|
|
|
|
+ const fl = document.querySelector(`#topo path[data-flow="${CSS.escape(l.kind + "|" + l.a + "|" + l.b)}"]`);
|
|
|
|
|
+ if (!fl) continue;
|
|
|
|
|
+ const B = ns[l.b], A = l.kind === "dac" ? ns[l.a] : null;
|
|
|
|
|
+ fl.classList.toggle("live", l.kind === "10g" ? !!B?.reachable
|
|
|
|
|
+ : l.kind === "200g" ? !!B?.occupied
|
|
|
|
|
+ : !!(B?.occupied && A?.occupied));
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const name of t.nodes) {
|
|
|
|
|
+ const lp = document.querySelector(`#topo rect[data-lanport="${CSS.escape(name)}"]`);
|
|
|
|
|
+ if (lp) lp.classList.toggle("on", !!ns[name]?.reachable);
|
|
|
|
|
+ const fp = document.querySelector(`#topo rect[data-fabport="${CSS.escape(name)}"]`);
|
|
|
|
|
+ if (fp) fp.classList.toggle("on", !!ns[name]?.occupied);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const sw of t.switches) {
|
|
|
|
|
+ const st = t.states.find((x) => x.entity === sw.entity);
|
|
|
|
|
+ const g = document.querySelector(`#topo [data-sw="${CSS.escape(sw.entity)}"]`);
|
|
|
|
|
+ if (!g) continue;
|
|
|
|
|
+ const cls = st?.on === true ? "on" : st?.on === false ? "off" : "unk";
|
|
|
|
|
+ g.classList.remove("on", "off", "unk");
|
|
|
|
|
+ g.classList.add(cls);
|
|
|
|
|
+ const led = g.querySelector(".swled");
|
|
|
|
|
+ if (led) led.setAttribute("class", "pled swled " + (st?.on === true ? "on" : st?.on === false ? "off" : ""));
|
|
|
|
|
+ const tt = g.querySelector("title");
|
|
|
|
|
+ if (tt) tt.textContent = `${sw.entity} · ${st?.on === true ? "on" : st?.on === false ? "OFF" : "state unknown"}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const c of snap.clusters) {
|
|
|
|
|
+ const g = document.querySelector(`#topo g[data-cluster="${CSS.escape(c.name)}"]`);
|
|
|
|
|
+ if (!g) continue;
|
|
|
|
|
+ g.classList.toggle("down", !c.up);
|
|
|
|
|
+ const dot = g.querySelector(".cdot");
|
|
|
|
|
+ if (dot) dot.setAttribute("class", "cdot " + (c.up ? "on" : "off"));
|
|
|
|
|
+ const tt = g.querySelector("title");
|
|
|
|
|
+ if (tt) tt.textContent = `${c.name} · ${c.up ? `up · ${c.free} free` : "down"}${c.switch_off ? " · HA switch off" : ""}`;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderTopo(snap) {
|
|
|
|
|
+ const host = document.getElementById("topo");
|
|
|
|
|
+ const t = snap.topo;
|
|
|
|
|
+ if (!host || !t) return;
|
|
|
|
|
+ const sig = topoSig(t);
|
|
|
|
|
+ if (state.topoSig !== sig) {
|
|
|
|
|
+ state.topoSig = sig;
|
|
|
|
|
+ host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
|
|
|
|
|
+ ${topoFrames(snap)}${cablesSvg(t)}${switchesSvg(t)}${devsSvg(t)}${legendSvg()}
|
|
|
|
|
+ </svg>`;
|
|
|
|
|
+ for (const g of host.querySelectorAll(".cluster-frame")) {
|
|
|
|
|
+ g.onclick = () => {
|
|
|
|
|
+ state.active = g.dataset.cluster;
|
|
|
|
|
+ localStorage.setItem("sm121-active", state.active);
|
|
|
|
|
+ if (wins.state) wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win);
|
|
|
|
|
+ onState(state.snap, true);
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ topoTick(snap);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// --- jobs ---------------------------------------------------------------------
|
|
// --- jobs ---------------------------------------------------------------------
|
|
|
// "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
|
|
// "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
|
|
|
// on live containers — jobs don't need to have started from this dashboard.
|
|
// on live containers — jobs don't need to have started from this dashboard.
|
|
@@ -1122,15 +1438,27 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").
|
|
|
|
|
|
|
|
// --- boot ---------------------------------------------------------------------------
|
|
// --- boot ---------------------------------------------------------------------------
|
|
|
function initManagedWindows() {
|
|
function initManagedWindows() {
|
|
|
- const W = innerWidth, H = innerHeight;
|
|
|
|
|
|
|
+ const W = innerWidth;
|
|
|
|
|
+ // vertical budget = the actual window layer (header/dock already excluded);
|
|
|
|
|
+ // +40 reserve for the dock, which appears when a window gets minimized
|
|
|
|
|
+ const L = Math.max(420, $("#winlayer").clientHeight - 40);
|
|
|
|
|
+ // topology map: the primary window (left); state/jobs stack on the right
|
|
|
|
|
+ const tw = Math.max(560, Math.floor(W * 0.58));
|
|
|
|
|
+ const rx = tw + 30, rw = Math.max(340, W - rx - 12);
|
|
|
|
|
+ const rh = Math.max(240, Math.floor(L * 0.48));
|
|
|
|
|
+ wins.topo = makeWin({
|
|
|
|
|
+ id: "win-topo", title: "Topology", x: 12, y: 12,
|
|
|
|
|
+ w: tw, h: L - 12,
|
|
|
|
|
+ bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
|
|
|
|
|
+ });
|
|
|
wins.state = makeWin({
|
|
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)),
|
|
|
|
|
|
|
+ id: "win-state", title: "Cluster state", x: rx, y: 12,
|
|
|
|
|
+ w: rw, h: rh,
|
|
|
bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
|
|
bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
|
|
|
});
|
|
});
|
|
|
wins.jobs = makeWin({
|
|
wins.jobs = makeWin({
|
|
|
- id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
|
|
|
|
|
- w: Math.max(320, W - Math.floor(W * 0.52) - 50), h: Math.max(320, Math.floor(H * 0.78)),
|
|
|
|
|
|
|
+ id: "win-jobs", title: "Jobs", x: rx, y: rh + 24,
|
|
|
|
|
+ w: rw, h: L - rh - 24,
|
|
|
bodyHtml: `<div class="jobs" id="jobs"><div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div></div>`,
|
|
bodyHtml: `<div class="jobs" id="jobs"><div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div></div>`,
|
|
|
});
|
|
});
|
|
|
renderDock();
|
|
renderDock();
|