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

web topo: cluster frames get per-cluster hues + depth-banded floors (bigger clusters pad further out), chips staggered per band so labels never collide; down = dashed + dim

Alexander Korolev 1 месяц назад
Родитель
Сommit
90c61b3c42
1 измененных файлов с 77 добавлено и 35 удалено
  1. 77 35
      src/web/ui.html

+ 77 - 35
src/web/ui.html

@@ -328,10 +328,11 @@
   @keyframes spin { to { transform: rotate(1turn); } }
   @keyframes spin { to { transform: rotate(1turn); } }
   #topo .cluster-frame { cursor: pointer; }
   #topo .cluster-frame { cursor: pointer; }
   #topo .cluster-frame:hover .frame { stroke-width: 2.2; }
   #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 .frame { stroke-width: 1.5; transition: opacity .4s; }
+  #topo .cluster-frame.down .frame { stroke-dasharray: 5 4; opacity: .5; }
+  #topo .cluster-frame.down .fchip rect { opacity: .6; }
+  #topo .fchip rect { fill: var(--panel); stroke-width: 1.2; }
+  #topo .fchip text { font-size: 10.5px; letter-spacing: .3px; font-weight: 600; }
   #topo .fchip .cdot { fill: var(--led-off); }
   #topo .fchip .cdot { fill: var(--led-off); }
   #topo .fchip .cdot.on { fill: var(--led-on); }
   #topo .fchip .cdot.on { fill: var(--led-on); }
   #topo .legend text { font-size: 10.5px; fill: var(--muted); }
   #topo .legend text { font-size: 10.5px; fill: var(--muted); }
@@ -752,16 +753,23 @@ function restoreControls(name, recipe, dry) {
 // DAC direct links, HA glyphs (fan) and cluster frames. The structure is
 // DAC direct links, HA glyphs (fan) and cluster frames. The structure is
 // rebuilt only when the topo signature changes (config edit); state ticks
 // rebuilt only when the topo signature changes (config edit); state ticks
 // just flip classes so flow/pulse/spin animations keep running.
 // just flip classes so flow/pulse/spin animations keep running.
-const TG_W = 1060, TG_H = 516;
+const TG_W = 1060, TG_H = 540;
 const tgPos = (i) => {
 const tgPos = (i) => {
   const cx = 20 + (i % 4) * 255;
   const cx = 20 + (i % 4) * 255;
-  return { x: cx + 37, y: i < 4 ? 30 : 370, w: 180, h: 104, dcx: cx + 127 };
+  return { x: cx + 37, y: i < 4 ? 74 : 386, w: 180, h: 104, dcx: cx + 127 };
 };
 };
 const tgSlot = (n) => {
 const tgSlot = (n) => {
   const m = String(n).match(/\d+/);
   const m = String(n).match(/\d+/);
   return m ? +m[0] : 999;
   return m ? +m[0] : 999;
 };
 };
 const tgIdx = (t, name) => t.nodes.findIndex((n) => n === name);
 const tgIdx = (t, name) => t.nodes.findIndex((n) => n === name);
+// cluster identity colors — green/red stay reserved for up/down state
+const TG_HUES = ["#58a6ff", "#a371f7", "#f778ba", "#f0883e", "#39c5cf", "#e3b341", "#ff7b72", "#79c0ff", "#d2a8ff", "#ffa657"];
+const hueOf = (name) => {
+  let h = 0;
+  for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
+  return TG_HUES[h % TG_HUES.length];
+};
 
 
 function topoSig(t) {
 function topoSig(t) {
   return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
   return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
@@ -776,15 +784,15 @@ function tgCablePath(l, t) {
     const px = 396 + bi * 38;
     const px = 396 + bi * 38;
     if (bi < 4) { // row0: device bottom → 10G switch top
     if (bi < 4) { // row0: device bottom → 10G switch top
       const ex = b.dcx + 40, ey = b.y + b.h;
       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 ${ex} ${ey} C ${ex} ${ey + 26} ${px} ${ey + 6} ${px} 206`;
     }
     }
-    return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 226`;
+    return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 262`;
   }
   }
   if (l.kind === "200g") { // row0: device bottom → fabric switch top
   if (l.kind === "200g") { // row0: device bottom → fabric switch top
     const ex = b.dcx - 40, ey = b.y + b.h;
     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 fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
     const px = 396 + fi * 80;
     const px = 396 + fi * 80;
-    return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 250`;
+    return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 286`;
   }
   }
   // dac: arc between adjacent device tops
   // dac: arc between adjacent device tops
   const ai = tgIdx(t, l.a);
   const ai = tgIdx(t, l.a);
@@ -816,7 +824,7 @@ function swSvg(sw, entries) {
   const label = sw.kind === "lan" ? "10G switch"
   const label = sw.kind === "lan" ? "10G switch"
     : sw.entity.includes(".") ? sw.entity.split(".")[1] : sw.entity;
     : sw.entity.includes(".") ? sw.entity.split(".")[1] : sw.entity;
   const ib = sw.kind === "ib";
   const ib = sw.kind === "ib";
-  const y = ib ? 250 : 170;
+  const y = ib ? 286 : 206;
   const sub = ib ? "200G fabric · HA" : "passive · no HA";
   const sub = ib ? "200G fabric · HA" : "passive · no HA";
   const step = ib ? 80 : 38;
   const step = ib ? 80 : 38;
   const rows = entries.map((p) => `<rect class="port" data-${ib ? "fab" : "lan"}port="${esc(p.name)}"
   const rows = entries.map((p) => `<rect class="port" data-${ib ? "fab" : "lan"}port="${esc(p.name)}"
@@ -834,45 +842,79 @@ function swSvg(sw, entries) {
 function fanSvg(sw) {
 function fanSvg(sw) {
   return `<g class="fan" data-sw="${esc(sw.entity)}">
   return `<g class="fan" data-sw="${esc(sw.entity)}">
     <title>${esc(sw.entity)}</title>
     <title>${esc(sw.entity)}</title>
-    <circle class="fbody" cx="770" cy="278" r="26"/>
+    <circle class="fbody" cx="770" cy="314" r="26"/>
     <g class="blades">
     <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"/>
+      <path class="blade" d="M770 290 C 781 294 783 306 775 312 L 770 314 Z"/>
+      <path class="blade" d="M770 290 C 781 294 783 306 775 312 L 770 314 Z" transform="rotate(120 770 314)"/>
+      <path class="blade" d="M770 290 C 781 294 783 306 775 312 L 770 314 Z" transform="rotate(240 770 314)"/>
+      <circle class="hub" cx="770" cy="314" r="5"/>
     </g>
     </g>
-    <text class="sub" x="770" y="322" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
+    <text class="sub" x="770" y="358" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
   </g>`;
   </g>`;
 }
 }
 
 
 function topoFrames(snap) {
 function topoFrames(snap) {
   const t = snap.topo;
   const t = snap.topo;
+  const sets = snap.clusters.map((c) => ({
+    name: c.name,
+    up: c.up,
+    nodes: c.nodes.map((n) => n.name),
+  }));
+  const contains = (a, b) => b.nodes.every((n) => a.nodes.includes(n));
+  const depthOf = (x) => sets.filter((o) => o !== x && contains(o, x)).length;
+  // pads grow with cluster rank in its region: the outermost (most
+  // containing) cluster sits furthest out, so label chips land on
+  // separate "floors" and never collide; inner frames hug their nodes
+  const pad = (c) => {
+    const d = depthOf(c);
+    const region = sets.filter((o) => o.nodes.some((n) => c.nodes.includes(n)));
+    const rmax = Math.max(...region.map(depthOf));
+    return { x: 14 + 10 * (rmax - d), top: 12 + 18 * (rmax - d), bottom: 12 };
+  };
   const rects = [];
   const rects = [];
-  for (const c of snap.clusters) {
-    const ps = c.nodes.map((n) => tgPos(tgIdx(t, n.name))).filter(Boolean);
+  for (const c of sets) {
+    const ps = c.nodes.map((n) => tgPos(tgIdx(t, n))).filter(Boolean);
     if (!ps.length) continue;
     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 });
+    const p = pad(c);
+    const x = Math.min(...ps.map((q) => q.x)) - p.x;
+    const y = Math.min(...ps.map((q) => q.y)) - p.top;
+    const w = Math.max(...ps.map((q) => q.x + q.w)) + p.x - x;
+    const h = Math.max(...ps.map((q) => q.y + q.h)) + p.bottom - y;
+    rects.push({ name: c.name, up: c.up, x, y, w, h, hue: hueOf(c.name), area: w * h });
   }
   }
   rects.sort((a, b) => b.area - a.area); // big frames behind, nested on top
   rects.sort((a, b) => b.area - a.area); // big frames behind, nested on top
-  const depthCount = {};
+  // a nested frame never shares its hue with a frame that contains it
+  // (containers sort first — bigger area — so their hues are final)
+  for (const r of rects) {
+    const containers = rects.filter((o) => o !== r && o.x <= r.x && o.y <= r.y
+      && o.x + o.w >= r.x + r.w && o.y + o.h >= r.y + r.h);
+    while (containers.some((o) => o.hue === r.hue)) {
+      r.hue = TG_HUES[(TG_HUES.indexOf(r.hue) + 1) % TG_HUES.length];
+    }
+  }
+  // chip bands: frames sharing a top edge get x-staggered chips so labels
+  // never overlap (identical node-sets render identical rects)
+  const bands = new Map();
+  for (const r of rects) (bands.get(r.y) ?? bands.set(r.y, []).get(r.y)).push(r);
+  for (const band of bands.values()) {
+    band.sort((a, b) => a.x - b.x || a.name.localeCompare(b.name));
+    let cursor = -1e9;
+    for (const r of band) {
+      r.cw = r.name.length * 6.6 + 30;
+      r.chipX = Math.max(r.x + 12, cursor);
+      cursor = r.chipX + r.cw + 6;
+    }
+  }
   let out = "";
   let out = "";
   for (const r of rects) {
   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)}">
+    out += `<g class="cluster-frame${r.up ? "" : " down"}" data-cluster="${esc(r.name)}">
     <title>${esc(r.name)}</title>
     <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"/>
+    <rect class="frame" style="stroke:${r.hue};fill:color-mix(in srgb, ${r.hue} 6%, transparent)"
+      x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14"/>
+    <g class="fchip" transform="translate(${r.chipX},${r.y - 8})">
+      <rect width="${r.cw}" height="16" rx="8" style="stroke:${r.hue}"/>
       <circle class="cdot" cx="10" cy="8" r="3.5"/>
       <circle class="cdot" cx="10" cy="8" r="3.5"/>
-      <text x="18" y="12">${esc(r.name)}</text>
+      <text x="18" y="12" style="fill:${r.hue}">${esc(r.name)}</text>
     </g>
     </g>
   </g>`;
   </g>`;
   }
   }
@@ -905,7 +947,7 @@ function switchesSvg(t) {
 const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
 const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
 
 
 function legendSvg() {
 function legendSvg() {
-  return `<g class="legend" transform="translate(20,502)">
+  return `<g class="legend" transform="translate(20,526)">
     <line x1="0" y1="0" x2="26" y2="0" class="cable c-200g"/><text x="32" y="4">200G fabric</text>
     <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="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>
     <line x1="240" y1="0" x2="266" y2="0" class="cable c-10g"/><text x="272" y="4">10G mgmt</text>