Ver código fonte

web: topology row-1 S-curve + bus/bridges cable mode (legend toggle)

Alexander Korolev 1 mês atrás
pai
commit
272842fcc9
2 arquivos alterados com 144 adições e 7 exclusões
  1. 78 7
      src/web/ui.html
  2. 66 0
      src/web/ui.playwright.mjs

+ 78 - 7
src/web/ui.html

@@ -335,6 +335,10 @@
   #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; }
+  /* bus-mode crossings: the 10g run hops over the 200g lane */
+  #topo .bridge { fill: none; stroke: color-mix(in srgb, var(--fg) 70%, transparent); stroke-width: 1.5; }
+  #topo .cablemode { cursor: pointer; fill: var(--muted); font-size: 11px; }
+  #topo .cablemode:hover { fill: var(--fg); text-decoration: underline; }
   @keyframes flow { to { stroke-dashoffset: -14; } }
   #topo .port { fill: var(--led-free); }
   #topo .port.on { fill: var(--led-on); }
@@ -1047,9 +1051,11 @@ function tgCablePath(l, t) {
       const ex = b.dcx - 40, ey = b.y + b.h;
       return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 290`;
     }
-    // row1: device top → fabric switch bottom
+    // row1: device top → fabric switch bottom — an S-curve inside the 40px
+    // corridor (switch bottom 346 .. device top 386) so the last approach
+    // to the port is vertical, not a slide in from the side
     const ex = b.dcx - 40, ey = b.y;
-    return `M ${ex} ${ey} C ${ex} ${ey - 76} ${px} ${ey - 66} ${px} 346`;
+    return `M ${ex} ${ey} C ${ex} 363 ${px} 363 ${px} 346`;
   }
   // dac: arc between adjacent device tops
   const ai = tgIdx(t, l.a);
@@ -1208,15 +1214,69 @@ function topoFrames(snap) {
   return { frames, chips };
 }
 
-function cablesSvg(t) {
+function cablesSvg(t, bus) {
   let out = "";
   for (const l of t.links) {
-    const p = tgCablePath(l, t);
+    const p = bus ? tgCablePathBus(l, t) : 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}"/>`;
   }
+  if (bus) out += bridgesSvg(t);
+  return out;
+}
+
+// Bus mode: row-1 (bottom) cables route as right-angle runs along two
+// shared lanes — 200g at y=366 (in the 40px corridor under the fabric
+// switch), 10g at y=278 (the 24px gap between the two switches). A 10g
+// cable whose vertical would cut the fabric switch detours around its
+// nearer side. Where a 10g run crosses the 200g lane it hops over it on
+// a bridge arc, so crossings read as intentional.
+function tgCablePathBus(l, t) {
+  const bi = tgIdx(t, l.b);
+  if (bi < 0 || bi < 4) return tgCablePath(l, t); // row 0 and DAC unchanged
+  const b = tgPos(bi);
+  if (l.kind === "200g") {
+    const ex = b.dcx - 40;
+    const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
+    const px = 396 + (fi % 4) * 80;
+    return `M ${ex} 386 L ${ex} 366 L ${px} 366 L ${px} 346`;
+  }
+  // 10g: up the device side, across the inter-switch gap, down to the port
+  const px = 396 + bi * 38;
+  const x = b.dcx;
+  if (x < 370 || x > 690) return `M ${x} 386 L ${x} 278 L ${px} 278 L ${px} 266`;
+  const side = x < 530 ? 350 : 710; // nearest clearance past the fabric box
+  return `M ${x} 386 L ${x} 366 L ${side} 366 L ${side} 278 L ${px} 278 L ${px} 266`;
+}
+
+// Small semicircle hops where a 10g row-1 vertical crosses the 200g lane.
+function bridgesSvg(t) {
+  const lane = t.links
+    .filter((l) => l.kind === "200g")
+    .map((l) => {
+      const bi = tgIdx(t, l.b);
+      if (bi < 4) return null;
+      const b = tgPos(bi);
+      const ex = b.dcx - 40;
+      const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
+      const px = 396 + (fi % 4) * 80;
+      return { y: 366, x1: Math.min(ex, px), x2: Math.max(ex, px) };
+    })
+    .filter(Boolean);
+  let out = "";
+  for (const l of t.links.filter((l) => l.kind === "10g")) {
+    const bi = tgIdx(t, l.b);
+    if (bi < 4) continue;
+    const x = tgPos(bi).dcx;
+    // detour devices turn ON the lane (a T-junction) — only a vertical that
+    // spans the lane (outer devices, x outside the fabric box) gets a hop
+    if (x >= 370 && x <= 690) continue;
+    for (const h of lane)
+      if (x > h.x1 + 2 && x < h.x2 - 2)
+        out += `<path class="bridge b-10g" d="M ${x - 5} ${h.y} A 5 5 0 0 1 ${x + 5} ${h.y}"/>`;
+  }
   return out;
 }
 
@@ -1233,13 +1293,14 @@ function switchesSvg(t) {
 
 const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
 
-function legendSvg() {
+function legendSvg(bus) {
   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="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>
+    <text x="560" y="4" class="cablemode" data-topobus="1">cables: ${bus ? "bus + bridges" : "direct"} ▸</text>
   </g>`;
 }
 
@@ -1330,12 +1391,13 @@ function renderTopo(snap) {
   const host = document.getElementById("topo");
   const t = snap.topo;
   if (!host || !t) return;
-  const sig = topoSig(t);
+  if (state.topoBus === undefined) state.topoBus = localStorage.getItem("sm121-topobus") === "1";
+  const sig = topoSig(t) + (state.topoBus ? ":bus" : ":direct");
   if (state.topoSig !== sig) {
     state.topoSig = sig;
     const tf = topoFrames(snap);
     host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
-      ${tf.frames}${cablesSvg(t)}${switchesSvg(t)}${devsSvg(t)}${legendSvg()}${tf.chips}
+      ${tf.frames}${cablesSvg(t, state.topoBus)}${switchesSvg(t)}${devsSvg(t)}${legendSvg(state.topoBus)}${tf.chips}
     </svg>`;
     for (const g of host.querySelectorAll("[data-cluster]")) {
       g.onclick = () => {
@@ -1345,6 +1407,15 @@ function renderTopo(snap) {
         onState(state.snap, true);
       };
     }
+    // cable-mode toggle (legend): direct bezier runs ↔ bus + bridge arcs
+    const cm = host.querySelector("[data-topobus]");
+    if (cm)
+      cm.onclick = () => {
+        state.topoBus = !state.topoBus;
+        localStorage.setItem("sm121-topobus", state.topoBus ? "1" : "0");
+        state.topoSig = ""; // force a re-render with the other mode
+        renderTopo(state.snap || snap);
+      };
   }
   topoTick(snap);
 }

+ 66 - 0
src/web/ui.playwright.mjs

@@ -559,6 +559,72 @@ ok("layout: minimized window stays minimized", back.stateMin);
 ok("layout: closed window stays closed, with a dock chip to reopen", back.jobsClosed && back.chips.includes("+ Jobs"), JSON.stringify(back.chips));
 ok("layout: maximized recipes window restored maximized", back.recipesMax && back.header === "Recipes", back.header);
 
+// --- topology cable mode: direct (S-curve) and bus + bridges --------------------
+{
+  await page.waitForFunction(() => state.snap && state.snap.topo && state.snap.topo.nodes);
+  const hasRow1 = await page.evaluate(() => state.snap.topo.nodes.length > 4);
+  const fab200 = await page.evaluate(() =>
+    [...document.querySelectorAll("#topo path.c-200g")].map((p) => p.getAttribute("d")),
+  );
+  const row1 = fab200.filter((d) => d && d.includes(" 386 "));
+  if (hasRow1) {
+    ok("topo: row-1 fabric cables present", row1.length > 0, `of ${fab200.length}`);
+    ok(
+      "topo: row-1 fabric cable approaches the port vertically (S-curve, no side entry)",
+      row1.every((d) => / C .* 366 .* 346$/.test(d)),
+      row1.join(" "),
+    );
+  } else {
+    ok("topo: no bottom-row devices in this fleet (S-curve path n/a)", row1.length === 0);
+  }
+
+  await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true })));
+  await page.waitForTimeout(400);
+  const bus = await page.evaluate(() => {
+    const t = state.snap.topo;
+    const lane = [];
+    for (const l of t.links.filter((x) => x.kind === "200g")) {
+      const fi = tgIdx(t, l.a);
+      if (fi < 4) continue;
+      const px = tgPos(fi).px;
+      const dx = tgPos(tgIdx(t, l.b)).dcx;
+      lane.push({ x1: Math.min(dx, px), x2: Math.max(dx, px) });
+    }
+    let expect = 0;
+    for (const l of t.links.filter((x) => x.kind === "10g")) {
+      const bi = tgIdx(t, l.b);
+      if (bi < 4) continue;
+      const x = tgPos(bi).dcx;
+      if (x >= 370 && x <= 690) continue;
+      if (lane.some((h) => x > h.x1 + 2 && x < h.x2 - 2)) expect++;
+    }
+    return {
+      legend: document.querySelector("#topo [data-topobus]").textContent,
+      manhattan: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" L ")).length,
+      bridges: document.querySelectorAll("#topo path.bridge").length,
+      expect,
+      bridgesDrawn: [...document.querySelectorAll("#topo path.bridge")].every((b) => (b.getAttribute("d") || "").includes("A 5 5")),
+    };
+  });
+  ok("topo: bus mode active (legend)", bus.legend.includes("bus"), bus.legend);
+  if (hasRow1)
+    ok("topo: bus cables run Manhattan (L segments)", bus.manhattan >= 1, `manhattan=${bus.manhattan}`);
+  ok("topo: bridges rendered exactly at strict crossings", bus.bridges === bus.expect && bus.bridgesDrawn, `bridges=${bus.bridges} expect=${bus.expect}`);
+
+  await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true })));
+  await page.waitForTimeout(400);
+  const back2 = await page.evaluate(() => ({
+    legend: document.querySelector("#topo [data-topobus]").textContent,
+    curved: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" C ")).length,
+  }));
+  ok(
+    "topo: back to direct mode",
+    back2.legend.includes("direct") && (fab200.length === 0 || back2.curved >= 1),
+    JSON.stringify(back2),
+  );
+  ok("topo: mode choice persisted", await page.evaluate(() => localStorage.getItem("sm121-topobus") === "0"));
+}
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));