|
@@ -328,9 +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 .fchip { cursor: pointer; }
|
|
|
|
|
+ #topo .fchip:hover rect { stroke-width: 2; }
|
|
|
|
|
+ #topo .fchip.down rect, #topo .fchip.down text { opacity: .55; }
|
|
|
#topo .frame { stroke-width: 1.5; transition: opacity .4s; }
|
|
#topo .frame { stroke-width: 1.5; transition: opacity .4s; }
|
|
|
#topo .cluster-frame.down .frame { stroke-dasharray: 5 4; opacity: .5; }
|
|
#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 rect { fill: var(--panel); stroke-width: 1.2; }
|
|
|
#topo .fchip text { font-size: 10.5px; letter-spacing: .3px; font-weight: 600; }
|
|
#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); }
|
|
@@ -784,15 +786,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} 206`;
|
|
|
|
|
|
|
+ return `M ${ex} ${ey} C ${ex} ${ey + 26} ${px} ${ey + 6} ${px} 210`;
|
|
|
}
|
|
}
|
|
|
- return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 262`;
|
|
|
|
|
|
|
+ return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 266`;
|
|
|
}
|
|
}
|
|
|
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} 286`;
|
|
|
|
|
|
|
+ return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 290`;
|
|
|
}
|
|
}
|
|
|
// dac: arc between adjacent device tops
|
|
// dac: arc between adjacent device tops
|
|
|
const ai = tgIdx(t, l.a);
|
|
const ai = tgIdx(t, l.a);
|
|
@@ -824,7 +826,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 ? 286 : 206;
|
|
|
|
|
|
|
+ const y = ib ? 290 : 210;
|
|
|
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)}"
|
|
@@ -842,82 +844,110 @@ 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="314" r="26"/>
|
|
|
|
|
|
|
+ <circle class="fbody" cx="770" cy="318" r="26"/>
|
|
|
<g class="blades">
|
|
<g class="blades">
|
|
|
- <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"/>
|
|
|
|
|
|
|
+ <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z"/>
|
|
|
|
|
+ <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z" transform="rotate(120 770 318)"/>
|
|
|
|
|
+ <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z" transform="rotate(240 770 318)"/>
|
|
|
|
|
+ <circle class="hub" cx="770" cy="318" r="5"/>
|
|
|
</g>
|
|
</g>
|
|
|
- <text class="sub" x="770" y="358" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
|
|
|
|
|
|
|
+ <text class="sub" x="770" y="362" 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),
|
|
|
|
|
- }));
|
|
|
|
|
|
|
+ // clusters with identical node sets share one frame (several chips on
|
|
|
|
|
+ // it) — no doubled rectangles, no mutual-containment depth inflation
|
|
|
|
|
+ const seen = new Map();
|
|
|
|
|
+ const groups = [];
|
|
|
|
|
+ for (const c of snap.clusters) {
|
|
|
|
|
+ const key = c.nodes.map((n) => n.name).sort().join("|");
|
|
|
|
|
+ if (seen.has(key)) seen.get(key).clusters.push(c);
|
|
|
|
|
+ else {
|
|
|
|
|
+ const g = { clusters: [c], nodes: c.nodes.map((n) => n.name) };
|
|
|
|
|
+ seen.set(key, g);
|
|
|
|
|
+ groups.push(g);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
const contains = (a, b) => b.nodes.every((n) => a.nodes.includes(n));
|
|
const contains = (a, b) => b.nodes.every((n) => a.nodes.includes(n));
|
|
|
- const depthOf = (x) => sets.filter((o) => o !== x && contains(o, x)).length;
|
|
|
|
|
|
|
+ const depthOf = (x) => groups.filter((o) => o !== x && contains(o, x)).length;
|
|
|
// pads grow with cluster rank in its region: the outermost (most
|
|
// 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)));
|
|
|
|
|
|
|
+ // containing) cluster sits furthest out on BOTH edges, so label chips
|
|
|
|
|
+ // land on separate "floors" and frame edges never stack into one line;
|
|
|
|
|
+ // inner frames hug their nodes
|
|
|
|
|
+ const pad = (g) => {
|
|
|
|
|
+ const d = depthOf(g);
|
|
|
|
|
+ const region = groups.filter((o) => o.nodes.some((n) => g.nodes.includes(n)));
|
|
|
const rmax = Math.max(...region.map(depthOf));
|
|
const rmax = Math.max(...region.map(depthOf));
|
|
|
- return { x: 14 + 10 * (rmax - d), top: 12 + 18 * (rmax - d), bottom: 12 };
|
|
|
|
|
|
|
+ return { x: 14 + 10 * (rmax - d), top: 12 + 18 * (rmax - d), bottom: 12 + 5 * (rmax - d) };
|
|
|
};
|
|
};
|
|
|
const rects = [];
|
|
const rects = [];
|
|
|
- for (const c of sets) {
|
|
|
|
|
- const ps = c.nodes.map((n) => tgPos(tgIdx(t, n))).filter(Boolean);
|
|
|
|
|
|
|
+ for (const g of groups) {
|
|
|
|
|
+ const ps = g.nodes.map((n) => tgPos(tgIdx(t, n))).filter(Boolean);
|
|
|
if (!ps.length) continue;
|
|
if (!ps.length) continue;
|
|
|
- const p = pad(c);
|
|
|
|
|
|
|
+ const p = pad(g);
|
|
|
const x = Math.min(...ps.map((q) => q.x)) - p.x;
|
|
const x = Math.min(...ps.map((q) => q.x)) - p.x;
|
|
|
const y = Math.min(...ps.map((q) => q.y)) - p.top;
|
|
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 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;
|
|
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.push({ g, x, y, w, h, 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
|
|
|
- // a nested frame never shares its hue with a frame that contains it
|
|
|
|
|
|
|
+ // a chip never shares its hue with a frame that contains its frame
|
|
|
// (containers sort first — bigger area — so their hues are final)
|
|
// (containers sort first — bigger area — so their hues are final)
|
|
|
for (const r of rects) {
|
|
for (const r of rects) {
|
|
|
const containers = rects.filter((o) => o !== r && o.x <= r.x && o.y <= r.y
|
|
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);
|
|
&& 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];
|
|
|
|
|
|
|
+ for (const c of r.g.clusters) {
|
|
|
|
|
+ let hue = hueOf(c.name);
|
|
|
|
|
+ while (containers.some((o) => o.hue === hue)) {
|
|
|
|
|
+ hue = TG_HUES[(TG_HUES.indexOf(hue) + 1) % TG_HUES.length];
|
|
|
|
|
+ }
|
|
|
|
|
+ c._hue = hue;
|
|
|
}
|
|
}
|
|
|
|
|
+ r.hue = r.g.clusters[0]._hue;
|
|
|
}
|
|
}
|
|
|
- // chip bands: frames sharing a top edge get x-staggered chips so labels
|
|
|
|
|
- // never overlap (identical node-sets render identical rects)
|
|
|
|
|
|
|
+ // chip bands: chips sharing a frame-top floor get x-staggered so labels
|
|
|
|
|
+ // never overlap (merged frames put several chips on one frame)
|
|
|
const bands = new Map();
|
|
const bands = new Map();
|
|
|
- for (const r of rects) (bands.get(r.y) ?? bands.set(r.y, []).get(r.y)).push(r);
|
|
|
|
|
|
|
+ for (const r of rects) {
|
|
|
|
|
+ for (const c of r.g.clusters) (bands.get(r.y) ?? bands.set(r.y, []).get(r.y)).push({ r, c });
|
|
|
|
|
+ }
|
|
|
for (const band of bands.values()) {
|
|
for (const band of bands.values()) {
|
|
|
- band.sort((a, b) => a.x - b.x || a.name.localeCompare(b.name));
|
|
|
|
|
|
|
+ band.sort((a, b) => a.r.x - b.r.x || a.c.name.localeCompare(b.c.name));
|
|
|
let cursor = -1e9;
|
|
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;
|
|
|
|
|
|
|
+ for (const chip of band) {
|
|
|
|
|
+ chip.cw = chip.c.name.length * 6.6 + 30;
|
|
|
|
|
+ chip.x = Math.max(chip.r.x + 12, cursor);
|
|
|
|
|
+ cursor = chip.x + chip.cw + 6;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ state.topoGroupTitles = {};
|
|
|
|
|
+ for (const r of rects) {
|
|
|
|
|
+ state.topoGroupTitles[r.g.clusters[0].name] = r.g.clusters.map((c) => c.name).join(" · ");
|
|
|
|
|
+ }
|
|
|
|
|
+ // pass 1: frame rectangles (all of them first — no frame edge ever
|
|
|
|
|
+ // crosses a chip); pass 2: chips on top
|
|
|
let out = "";
|
|
let out = "";
|
|
|
for (const r of rects) {
|
|
for (const r of rects) {
|
|
|
- out += `<g class="cluster-frame${r.up ? "" : " down"}" data-cluster="${esc(r.name)}">
|
|
|
|
|
- <title>${esc(r.name)}</title>
|
|
|
|
|
|
|
+ out += `<g class="cluster-frame" data-cluster="${esc(r.g.clusters[0].name)}">
|
|
|
|
|
+ <title>${esc(r.g.clusters.map((c) => c.name).join(" · "))}</title>
|
|
|
<rect class="frame" style="stroke:${r.hue};fill:color-mix(in srgb, ${r.hue} 6%, transparent)"
|
|
<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"/>
|
|
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"/>
|
|
|
|
|
- <text x="18" y="12" style="fill:${r.hue}">${esc(r.name)}</text>
|
|
|
|
|
- </g>
|
|
|
|
|
</g>`;
|
|
</g>`;
|
|
|
}
|
|
}
|
|
|
|
|
+ for (const r of rects) {
|
|
|
|
|
+ for (const c of r.g.clusters) {
|
|
|
|
|
+ const chip = bands.get(r.y).find((x) => x.c === c);
|
|
|
|
|
+ out += `<g class="fchip" data-cluster="${esc(c.name)}" transform="translate(${chip.x},${r.y - 8})">
|
|
|
|
|
+ <title>${esc(c.name)}</title>
|
|
|
|
|
+ <rect width="${chip.cw}" height="16" rx="8" style="stroke:${c._hue}"/>
|
|
|
|
|
+ <circle class="cdot" cx="10" cy="8" r="3.5"/>
|
|
|
|
|
+ <text x="18" y="12" style="fill:${c._hue}">${esc(c.name)}</text>
|
|
|
|
|
+ </g>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
return out;
|
|
return out;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1014,13 +1044,14 @@ function topoTick(snap) {
|
|
|
if (tt) tt.textContent = `${sw.entity} · ${st?.on === true ? "on" : st?.on === false ? "OFF" : "state unknown"}`;
|
|
if (tt) tt.textContent = `${sw.entity} · ${st?.on === true ? "on" : st?.on === false ? "OFF" : "state unknown"}`;
|
|
|
}
|
|
}
|
|
|
for (const c of snap.clusters) {
|
|
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" : ""}`;
|
|
|
|
|
|
|
+ const st = `${c.up ? `up · ${c.free} free` : "down"}${c.switch_off ? " · HA switch off" : ""}`;
|
|
|
|
|
+ for (const g of document.querySelectorAll(`#topo [data-cluster="${CSS.escape(c.name)}"]`)) {
|
|
|
|
|
+ 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 = `${(state.topoGroupTitles || {})[c.name] || c.name} · ${st}`;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1034,7 +1065,7 @@ function renderTopo(snap) {
|
|
|
host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
|
|
host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
|
|
|
${topoFrames(snap)}${cablesSvg(t)}${switchesSvg(t)}${devsSvg(t)}${legendSvg()}
|
|
${topoFrames(snap)}${cablesSvg(t)}${switchesSvg(t)}${devsSvg(t)}${legendSvg()}
|
|
|
</svg>`;
|
|
</svg>`;
|
|
|
- for (const g of host.querySelectorAll(".cluster-frame")) {
|
|
|
|
|
|
|
+ for (const g of host.querySelectorAll("[data-cluster]")) {
|
|
|
g.onclick = () => {
|
|
g.onclick = () => {
|
|
|
state.active = g.dataset.cluster;
|
|
state.active = g.dataset.cluster;
|
|
|
localStorage.setItem("sm121-active", state.active);
|
|
localStorage.setItem("sm121-active", state.active);
|