|
@@ -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>
|