Add VLAN inventory, Present decks, and network fabric mapping.
Seed all ATC/FDE VLANs with live host inventory and editable notes, fix cluster membership on the map, and ship Present/network UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+444
-89
@@ -18,8 +18,60 @@
|
||||
wireDeviceId: null,
|
||||
anim: 0,
|
||||
raf: 0,
|
||||
peerOrder: {}, // switchId -> [port,...]
|
||||
cableColors: {}, // `${switchId}:${port}` -> #hex
|
||||
};
|
||||
|
||||
const CABLE_PALETTE = ["#ff9a3c", "#3dffe0", "#7ec8ff", "#ff5c5c", "#c4a7ff", "#3dffa0", "#f0e68c", "#f472b6"];
|
||||
|
||||
function loadFabricPrefs() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem("cockpit_fabric_prefs") || "{}");
|
||||
if (raw.peerOrder) state.peerOrder = raw.peerOrder;
|
||||
if (raw.cableColors) state.cableColors = raw.cableColors;
|
||||
} catch (_) {}
|
||||
}
|
||||
function saveFabricPrefs() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"cockpit_fabric_prefs",
|
||||
JSON.stringify({ peerOrder: state.peerOrder, cableColors: state.cableColors })
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
loadFabricPrefs();
|
||||
|
||||
function cableColorKey(port) {
|
||||
return `${state.selectedSwitchId || 0}:${port}`;
|
||||
}
|
||||
function getCableColor(port) {
|
||||
return state.cableColors[cableColorKey(port)] || CABLE_PALETTE[(Number(port) - 1) % CABLE_PALETTE.length];
|
||||
}
|
||||
function setCableColor(port, hex) {
|
||||
state.cableColors[cableColorKey(port)] = hex;
|
||||
saveFabricPrefs();
|
||||
}
|
||||
function hexToRgba(hex, a) {
|
||||
const h = String(hex || "#ff9a3c").replace("#", "");
|
||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
const n = parseInt(full, 16);
|
||||
if (!Number.isFinite(n)) return `rgba(255,154,60,${a})`;
|
||||
const r = (n >> 16) & 255;
|
||||
const g = (n >> 8) & 255;
|
||||
const b = n & 255;
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
function orderedWiredPorts(ports) {
|
||||
const wired = (ports || []).filter((p) => p.wired && p.link?.device);
|
||||
const order = state.peerOrder[state.selectedSwitchId] || [];
|
||||
const rank = new Map(order.map((p, i) => [Number(p), i]));
|
||||
return wired.slice().sort((a, b) => {
|
||||
const ra = rank.has(a.port) ? rank.get(a.port) : 1000 + a.port;
|
||||
const rb = rank.has(b.port) ? rank.get(b.port) : 1000 + b.port;
|
||||
return ra - rb;
|
||||
});
|
||||
}
|
||||
|
||||
async function api(method, url, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
@@ -207,40 +259,47 @@
|
||||
<span class="sw-wired-pill">${pm.wired_count}/${sw.port_count} ports wired</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-port-grid" id="sw-port-grid">
|
||||
${ports
|
||||
.map((p) => {
|
||||
const cls = [
|
||||
"sw-port",
|
||||
p.wired ? "wired" : "empty",
|
||||
selected === p.port ? "selected" : "",
|
||||
p.link?.device?.connected ? "live" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const tip = p.wired
|
||||
? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}`
|
||||
: `${p.label} — click to wire`;
|
||||
return `<button type="button" class="${cls}" data-port="${p.port}" title="${escape(tip)}">
|
||||
<span class="sw-port-num">${p.port}</span>
|
||||
${p.wired ? '<span class="sw-port-dot"></span>' : ""}
|
||||
</button>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="sw-cable-stage">
|
||||
<canvas id="sw-cable-canvas"></canvas>
|
||||
<div class="sw-peer-rail" id="sw-peer-rail">
|
||||
<div class="sw-cable-stage" id="sw-cable-stage">
|
||||
<canvas id="sw-cable-canvas" aria-hidden="true"></canvas>
|
||||
<div class="sw-port-grid" id="sw-port-grid">
|
||||
${ports
|
||||
.filter((p) => p.wired && p.link?.device)
|
||||
.map((p) => {
|
||||
const cls = [
|
||||
"sw-port",
|
||||
p.wired ? "wired" : "empty",
|
||||
selected === p.port ? "selected" : "",
|
||||
p.link?.device?.connected ? "live" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const tip = p.wired
|
||||
? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}`
|
||||
: `${p.label} — click to wire`;
|
||||
return `<button type="button" class="${cls}" data-port="${p.port}" title="${escape(tip)}">
|
||||
<span class="sw-port-num">${p.port}</span>
|
||||
${p.wired ? '<span class="sw-port-dot"></span>' : ""}
|
||||
</button>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="sw-peer-rail" id="sw-peer-rail">
|
||||
<p class="sw-peer-hint">Drag servers to rearrange · pick cable color per link</p>
|
||||
${orderedWiredPorts(ports)
|
||||
.map((p) => {
|
||||
const d = p.link.device;
|
||||
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" data-peer-port="${p.port}">
|
||||
const col = getCableColor(p.port);
|
||||
const nicLabel = p.link.device_port || "—";
|
||||
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" draggable="true" data-peer-port="${p.port}" style="--cable:${escape(col)}">
|
||||
<span class="sw-peer-grip" title="Drag to reorder" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sw-peer-port">P${p.port}</span>
|
||||
<div>
|
||||
<div class="sw-peer-body">
|
||||
<strong>${escape(d.name)}</strong>
|
||||
<span class="mono">NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")}</span>
|
||||
<span class="mono">${escape(nicLabel)} · ST=${escape(d.service_tag || "—")}</span>
|
||||
<span class="sw-peer-status">${d.connected ? "connected" : "offline"}</span>
|
||||
</div>
|
||||
<label class="sw-cable-color" title="Cable / pulse color">
|
||||
<input type="color" value="${escape(col)}" data-cable-color="${p.port}" />
|
||||
</label>
|
||||
</div>`;
|
||||
})
|
||||
.join("") || '<p class="hint">No ports wired yet — click a port on the switch.</p>'}
|
||||
@@ -276,7 +335,65 @@
|
||||
$("#sw-wire-save")?.addEventListener("click", saveWire);
|
||||
$("#sw-wire-clear")?.addEventListener("click", clearWire);
|
||||
$("#sw-wire-device")?.addEventListener("change", onWireDeviceChange);
|
||||
requestAnimationFrame(() => drawCables());
|
||||
if ($("#sw-wire-device")?.value) onWireDeviceChange();
|
||||
bindPeerRail($("#sw-peer-rail"));
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => drawCables());
|
||||
});
|
||||
}
|
||||
|
||||
function bindPeerRail(rail) {
|
||||
if (!rail) return;
|
||||
let dragPort = null;
|
||||
rail.querySelectorAll("[data-cable-color]").forEach((inp) => {
|
||||
inp.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
const port = Number(inp.dataset.cableColor);
|
||||
setCableColor(port, inp.value);
|
||||
const chip = inp.closest(".sw-peer-chip");
|
||||
if (chip) chip.style.setProperty("--cable", inp.value);
|
||||
drawCables();
|
||||
});
|
||||
inp.addEventListener("click", (e) => e.stopPropagation());
|
||||
inp.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
});
|
||||
rail.querySelectorAll(".sw-peer-chip[draggable]").forEach((chip) => {
|
||||
chip.addEventListener("dragstart", (e) => {
|
||||
dragPort = Number(chip.dataset.peerPort);
|
||||
chip.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(dragPort));
|
||||
});
|
||||
chip.addEventListener("dragend", () => {
|
||||
chip.classList.remove("dragging");
|
||||
rail.querySelectorAll(".sw-peer-chip").forEach((n) => n.classList.remove("drag-over"));
|
||||
dragPort = null;
|
||||
drawCables();
|
||||
});
|
||||
chip.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
chip.classList.add("drag-over");
|
||||
});
|
||||
chip.addEventListener("dragleave", () => chip.classList.remove("drag-over"));
|
||||
chip.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
chip.classList.remove("drag-over");
|
||||
const from = Number(e.dataTransfer.getData("text/plain") || dragPort);
|
||||
const to = Number(chip.dataset.peerPort);
|
||||
if (!from || !to || from === to) return;
|
||||
const ports = orderedWiredPorts(state.portmap?.ports || []).map((p) => p.port);
|
||||
const fi = ports.indexOf(from);
|
||||
const ti = ports.indexOf(to);
|
||||
if (fi < 0 || ti < 0) return;
|
||||
ports.splice(fi, 1);
|
||||
ports.splice(ti, 0, from);
|
||||
state.peerOrder[state.selectedSwitchId] = ports;
|
||||
saveFabricPrefs();
|
||||
renderFabric();
|
||||
startPulse();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderWirePane(selPort, servers) {
|
||||
@@ -304,7 +421,10 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sw-field">Server port / NIC
|
||||
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="e.g. NIC.Slot.1-1 / eth0 / Port 2" value="${escape(curPort)}" />
|
||||
<select id="sw-wire-nic-pick">
|
||||
<option value="">— load after selecting server —</option>
|
||||
</select>
|
||||
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="PortId / FQDD (e.g. NIC.Slot.1-1)" value="${escape(curPort)}" />
|
||||
<datalist id="sw-nic-list"></datalist>
|
||||
</label>
|
||||
<label class="sw-field">Note
|
||||
@@ -330,21 +450,51 @@
|
||||
const id = Number($("#sw-wire-device")?.value || 0);
|
||||
state.wireDeviceId = id || null;
|
||||
const list = $("#sw-nic-list");
|
||||
if (!list || !id) return;
|
||||
list.innerHTML = "";
|
||||
const pick = $("#sw-wire-nic-pick");
|
||||
const input = $("#sw-wire-nic");
|
||||
if (list) list.innerHTML = "";
|
||||
if (pick) {
|
||||
pick.innerHTML = id
|
||||
? `<option value="">Loading NIC ports from OME…</option>`
|
||||
: `<option value="">— select a server first —</option>`;
|
||||
}
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await api("GET", `/api/devices/${id}/nics`);
|
||||
(data.nics || []).forEach((n) => {
|
||||
const rows = data.nics || [];
|
||||
if (pick) {
|
||||
pick.innerHTML =
|
||||
`<option value="">— ${rows.length ? "select NIC port" : "no NIC ports in OME"} —</option>` +
|
||||
rows
|
||||
.map((n) => {
|
||||
const val = n.port_id || n.fqdd || n.name || "";
|
||||
const label = n.label || [n.port_id || n.fqdd, n.name, n.mac].filter(Boolean).join(" · ");
|
||||
return `<option value="${escape(val)}" title="${escape(label)}">${escape(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
if (input?.value) {
|
||||
const match = [...pick.options].find((o) => o.value && o.value === input.value);
|
||||
if (match) pick.value = match.value;
|
||||
}
|
||||
pick.onchange = () => {
|
||||
if (pick.value && input) input.value = pick.value;
|
||||
};
|
||||
}
|
||||
rows.forEach((n) => {
|
||||
if (!list) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = n.fqdd || n.name;
|
||||
opt.label = `${n.name}${n.mac ? " · " + n.mac : ""}`;
|
||||
const val = n.port_id || n.fqdd || n.name || "";
|
||||
opt.value = val;
|
||||
opt.label = n.label || val;
|
||||
list.appendChild(opt);
|
||||
});
|
||||
if ((data.nics || [])[0] && !$("#sw-wire-nic")?.value) {
|
||||
$("#sw-wire-nic").value = data.nics[0].fqdd || data.nics[0].name || "";
|
||||
if (rows[0] && input && !input.value) {
|
||||
const first = rows[0].port_id || rows[0].fqdd || rows[0].name || "";
|
||||
input.value = first;
|
||||
if (pick) pick.value = first;
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
if (pick) pick.innerHTML = `<option value="">NIC lookup failed — type PortId manually</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +539,7 @@
|
||||
const canvas = $("#sw-cable-canvas");
|
||||
const grid = $("#sw-port-grid");
|
||||
const rail = $("#sw-peer-rail");
|
||||
const stage = canvas?.parentElement;
|
||||
const stage = $("#sw-cable-stage") || canvas?.parentElement;
|
||||
if (!canvas || !grid || !stage || state.tab !== "fabric") return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = stage.clientWidth;
|
||||
@@ -414,39 +564,57 @@
|
||||
if (!portBtn || !peer) return;
|
||||
const a = portBtn.getBoundingClientRect();
|
||||
const b = peer.getBoundingClientRect();
|
||||
/* Attach to port bottom-center → peer left-center (all in stage coords) */
|
||||
const x1 = a.left + a.width / 2 - stageBox.left;
|
||||
const y1 = a.bottom - stageBox.top;
|
||||
const x2 = b.left - stageBox.left;
|
||||
const y2 = b.top + b.height / 2 - stageBox.top;
|
||||
const live = !!p.link?.device?.connected;
|
||||
const midY = y1 + Math.max(24, (y2 - y1) * 0.35);
|
||||
if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return;
|
||||
|
||||
const live = !!p.link?.device?.connected;
|
||||
const col = getCableColor(p.port);
|
||||
const dy = Math.max(28, y2 - y1);
|
||||
const c1x = x1;
|
||||
const c1y = y1 + dy * 0.45;
|
||||
const c2x = x2 - Math.min(80, Math.max(36, (x2 - x1) * 0.35));
|
||||
const c2y = y2;
|
||||
|
||||
/* Base dashed cable — user-chosen color */
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2);
|
||||
ctx.strokeStyle = live ? "rgba(61,255,224,0.55)" : "rgba(0,168,232,0.35)";
|
||||
ctx.lineWidth = live ? 2.4 : 1.6;
|
||||
ctx.setLineDash([8, 7]);
|
||||
ctx.lineDashOffset = -t * (live ? 55 : 22);
|
||||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
|
||||
ctx.strokeStyle = hexToRgba(col, live ? 0.72 : 0.4);
|
||||
ctx.lineWidth = live ? 2.6 : 2.2;
|
||||
ctx.lineCap = "round";
|
||||
ctx.setLineDash([7, 6]);
|
||||
ctx.lineDashOffset = -t * (live ? 50 : 28);
|
||||
ctx.stroke();
|
||||
|
||||
if (live) {
|
||||
const pulse = (Math.sin(t * 5 + p.port) + 1) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2);
|
||||
ctx.strokeStyle = `rgba(255,154,60,${0.2 + pulse * 0.55})`;
|
||||
ctx.lineWidth = 3.2;
|
||||
ctx.setLineDash([]);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// moving packet
|
||||
const u = (t * 0.35 + p.port * 0.07) % 1;
|
||||
const pt = bezierPoint(x1, y1, x1, midY, x2 - 40, y2, x2, y2, u);
|
||||
/* Glow pulse */
|
||||
const pulse = (Math.sin(t * 5 + p.port) + 1) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, live ? 3.2 : 2.2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = live ? "#ff9a3c" : "#00a8e8";
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
|
||||
ctx.strokeStyle = hexToRgba(col, (live ? 0.22 : 0.1) + pulse * (live ? 0.5 : 0.25));
|
||||
ctx.lineWidth = live ? 3.4 : 2.8;
|
||||
ctx.setLineDash([]);
|
||||
ctx.stroke();
|
||||
|
||||
/* Moving packet */
|
||||
const u = (t * 0.35 + p.port * 0.07) % 1;
|
||||
const pt = bezierPoint(x1, y1, c1x, c1y, c2x, c2y, x2, y2, u);
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, live ? 3.4 : 2.8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
|
||||
/* Anchor dots */
|
||||
ctx.beginPath();
|
||||
ctx.arc(x1, y1, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x2, y2, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
@@ -744,7 +912,7 @@
|
||||
<header class="rd-overview-head">
|
||||
<div>
|
||||
<h3>Rack floor · ${escape(state.site === "Both" ? "ATC1 + ATC2" : state.site)}</h3>
|
||||
<p>Klik een rack om de Design Studio te openen (inzoomen, alle 42U, Dell catalogus).</p>
|
||||
<p>Click a rack to open Design Studio (zoom, all 42U, Dell catalog).</p>
|
||||
</div>
|
||||
<div class="rd-legend">
|
||||
<span class="lg server">Server</span><span class="lg switch">Switch</span>
|
||||
@@ -865,7 +1033,7 @@
|
||||
|
||||
<main class="rs-canvas-wrap">
|
||||
<div class="rs-statusbar">
|
||||
<span id="rs-status-text">Sleep stencils uit de bibliotheek naar het rack · drop op een U</span>
|
||||
<span id="rs-status-text">Drag stencils from the library onto a U</span>
|
||||
<span class="rs-status-side">${escape((state.rackSide || "front").toUpperCase())} view</span>
|
||||
</div>
|
||||
<div class="rs-canvas visio" id="rs-canvas">
|
||||
@@ -1052,7 +1220,7 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
el.classList.add("rs-dragging");
|
||||
setStatus(`Sleep ${name} (${uHeight}U) — drop op de onderkant-U (belegt omhoog)`);
|
||||
setStatus(`Dragging ${name} (${uHeight}U) — drop on the bottom U (fills upward)`);
|
||||
node.classList.add("dragging");
|
||||
}
|
||||
|
||||
@@ -1074,7 +1242,7 @@
|
||||
e.dataTransfer.setData("application/x-rack-item", raw);
|
||||
e.dataTransfer.setData("text/plain", raw);
|
||||
el.classList.add("rs-dragging");
|
||||
setStatus(`Verplaatsen (${h}U): drop op nieuwe onderkant-U`);
|
||||
setStatus(`Moving (${h}U): drop on a new bottom U`);
|
||||
node.classList.add("dragging");
|
||||
}
|
||||
|
||||
@@ -1086,7 +1254,7 @@
|
||||
el.classList.remove("rs-dragging");
|
||||
if (ghost) ghost.hidden = true;
|
||||
dragPayload = null;
|
||||
setStatus("Sleep stencils uit de bibliotheek naar het rack · drop op onderkant-U");
|
||||
setStatus("Drag stencils from the library onto a bottom U");
|
||||
});
|
||||
// click still selects for keyboard users
|
||||
node.addEventListener("click", () => {
|
||||
@@ -1099,7 +1267,7 @@
|
||||
state.placeSku = node.dataset.sku || null;
|
||||
state.placeH = Number(node.dataset.h || 1);
|
||||
}
|
||||
setStatus(`Geselecteerd: ${node.dataset.name} — sleep of klik een U`);
|
||||
setStatus(`Selected: ${node.dataset.name} — drag or click a U`);
|
||||
el.querySelectorAll(".rs-stencil").forEach((n) => n.classList.toggle("active", n === node));
|
||||
});
|
||||
});
|
||||
@@ -1130,7 +1298,7 @@
|
||||
if (ok) {
|
||||
setStatus(`Drop → ${span.label} (${span.height}U)`);
|
||||
} else {
|
||||
setStatus(`Past niet op ${span.label} — overlap of buiten U1–U${rk?.units || 42}`);
|
||||
setStatus(`Does not fit on ${span.label} — overlap or outside U1–U${rk?.units || 42}`);
|
||||
}
|
||||
});
|
||||
zone.addEventListener("dragleave", (e) => {
|
||||
@@ -1153,7 +1321,7 @@
|
||||
const rk = focusRack();
|
||||
const exclude = payload.kind === "move" ? payload.item_id : null;
|
||||
if (!unitsFree(rk, u, h, exclude)) {
|
||||
setStatus("Drop geblokkeerd — U-bereik niet vrij");
|
||||
setStatus("Drop blocked — U range is not free");
|
||||
return;
|
||||
}
|
||||
state._dragMoved = true;
|
||||
@@ -1182,10 +1350,10 @@
|
||||
}
|
||||
renderRacks();
|
||||
renderStudio();
|
||||
setStatus(`Geplaatst op ${uSpan(u, h).label}`);
|
||||
setStatus(`Placed on ${uSpan(u, h).label}`);
|
||||
} catch (err) {
|
||||
alert("Drop failed: " + err.message);
|
||||
setStatus("Drop mislukt — probeer een vrije U");
|
||||
setStatus("Drop failed — try a free U");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1198,7 +1366,7 @@
|
||||
const h = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, state.placeH || 1);
|
||||
const rk = focusRack();
|
||||
if (!unitsFree(rk, u, h, null)) {
|
||||
setStatus(`Past niet op ${uSpan(u, h).label}`);
|
||||
setStatus(`Does not fit on ${uSpan(u, h).label}`);
|
||||
return;
|
||||
}
|
||||
state.placeU = u;
|
||||
@@ -1213,7 +1381,7 @@
|
||||
const rk = focusRack();
|
||||
const ok = unitsFree(rk, u, h, null);
|
||||
highlightSpan(u, h, ok);
|
||||
setStatus(ok ? `Klik → ${uSpan(u, h).label}` : `Past niet op ${uSpan(u, h).label}`);
|
||||
setStatus(ok ? `Click → ${uSpan(u, h).label}` : `Does not fit on ${uSpan(u, h).label}`);
|
||||
});
|
||||
node.addEventListener("mouseleave", () => {
|
||||
if (!dragPayload) clearDropHighlight();
|
||||
@@ -1236,7 +1404,7 @@
|
||||
const side = state.rackSide || "front";
|
||||
const rk = focusRack();
|
||||
if (!unitsFree(rk, uStart, height, null)) {
|
||||
alert(`Past niet op ${uSpan(uStart, height).label}`);
|
||||
alert(`Does not fit on ${uSpan(uStart, height).label}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1365,12 +1533,68 @@
|
||||
}
|
||||
|
||||
/* ===== VLANs ===== */
|
||||
function rackInfoForDevice(deviceId) {
|
||||
if (deviceId == null || !state.racks?.sites) return null;
|
||||
for (const site of state.racks.sites) {
|
||||
for (const rk of site.racks || []) {
|
||||
for (const it of rk.items || []) {
|
||||
if (it.device_id === deviceId) {
|
||||
const span = uSpan(it.u_start, it.u_height || 1);
|
||||
return {
|
||||
site: site.name || site.id,
|
||||
rack: rk.name,
|
||||
uLabel: span.label,
|
||||
side: it.side || "front",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function switchLinksForDevice(deviceId) {
|
||||
const fromMember = [];
|
||||
// Prefer API-enriched port_links (all switches), fall back to selected faceplate
|
||||
const all = state.vlans?.port_links || [];
|
||||
all
|
||||
.filter((l) => l.device_id === deviceId)
|
||||
.forEach((l) => fromMember.push({ port: l.switch_port, switch_id: l.switch_id }));
|
||||
if (fromMember.length) return fromMember;
|
||||
const ports = state.portmap?.ports || [];
|
||||
return ports.filter((p) => p.wired && p.link?.device_id === deviceId);
|
||||
}
|
||||
|
||||
function formatSwitchPorts(ports) {
|
||||
if (!ports || !ports.length) return "none mapped";
|
||||
return ports
|
||||
.map((p) => {
|
||||
const name = p.switch_name || (p.switch_id != null ? `sw ${p.switch_id}` : "switch");
|
||||
const port = p.switch_port != null ? `P${p.switch_port}` : p.port != null ? `P${p.port}` : "port ?";
|
||||
const hint = p.inferred ? " (hint)" : "";
|
||||
return `${name} · ${port}${hint}`;
|
||||
})
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function renderVlans() {
|
||||
const root = $("#network-vlans");
|
||||
if (!root || !state.vlans) return;
|
||||
const vlans = state.vlans.vlans || [];
|
||||
if (state.selectedVlanId == null && vlans[0]) state.selectedVlanId = vlans[0].id;
|
||||
const sel = vlans.find((v) => v.id === state.selectedVlanId) || vlans[0];
|
||||
const members = sel?.members || [];
|
||||
const up = members.filter((m) => m.connected).length;
|
||||
const roles = {};
|
||||
members.forEach((m) => {
|
||||
const r = (m.role || "other").toLowerCase();
|
||||
roles[r] = (roles[r] || 0) + 1;
|
||||
});
|
||||
const matrix = state.vlans.attachment_matrix || [];
|
||||
const matrixFocus = matrix.filter((row) => {
|
||||
if (!sel) return true;
|
||||
return (row.vlans || []).some((v) => v.vlan_id === sel.vlan_id || v.cidr === sel.cidr);
|
||||
});
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="vlan-layout">
|
||||
@@ -1381,11 +1605,11 @@
|
||||
${vlans
|
||||
.map((v) => {
|
||||
const active = v.id === state.selectedVlanId ? " active" : "";
|
||||
return `<button type="button" class="vlan-card${active}" data-vlan="${v.id}" style="--vlan:${escape(v.color || "#00a8e8")}">
|
||||
return `<button type="button" class="vlan-card${active}" data-vlan="${v.id}" style="--vlan:${escape(v.color || "#ff9a3c")}">
|
||||
<span class="vlan-id">VLAN ${v.vlan_id ?? "—"}</span>
|
||||
<strong>${escape(v.name)}</strong>
|
||||
<span class="mono">${escape(v.cidr)}</span>
|
||||
<span>${v.member_count} endpoints · ${v.connected_count} connected</span>
|
||||
<span class="vlan-card-meta">${v.member_count} nodes · ${v.connected_count} up · ${escape(v.site_id || "—")}</span>
|
||||
</button>`;
|
||||
})
|
||||
.join("")}
|
||||
@@ -1394,7 +1618,7 @@
|
||||
? `<h4>Discovered (not in catalog)</h4>
|
||||
<ul class="vlan-discovered">
|
||||
${(state.vlans.discovered_subnets || [])
|
||||
.map((d) => `<li><span class="mono">${escape(d.cidr)}</span> · ${d.device_ips} IPs</li>`)
|
||||
.map((d) => `<li><span class="mono">${escape(d.cidr)}</span> · ${d.device_ips} device IPs seen</li>`)
|
||||
.join("")}
|
||||
</ul>`
|
||||
: ""}
|
||||
@@ -1402,22 +1626,113 @@
|
||||
<section class="vlan-detail-pane">
|
||||
${
|
||||
sel
|
||||
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#00a8e8")}">
|
||||
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#ff9a3c")}">
|
||||
<div>
|
||||
<h3>VLAN ${sel.vlan_id ?? "—"} · ${escape(sel.name)}</h3>
|
||||
<p class="mono">${escape(sel.cidr)} · ${escape(sel.site_id || "—")} · ${escape(sel.purpose || "")}</p>
|
||||
<p class="mono">${escape(sel.cidr)}</p>
|
||||
<p class="vlan-purpose">${escape(sel.purpose || "No purpose set")} · site ${escape(sel.site_id || "—")}</p>
|
||||
</div>
|
||||
<div class="vlan-stat-stack">
|
||||
<span class="vlan-stat">${members.length} nodes</span>
|
||||
<span class="vlan-stat soft">${up} connected</span>
|
||||
</div>
|
||||
<span class="vlan-stat">${sel.member_count} members</span>
|
||||
</header>
|
||||
<div class="vlan-context">
|
||||
<div class="vlan-kpi"><strong>${members.length}</strong><span>Endpoints</span></div>
|
||||
<div class="vlan-kpi"><strong>${up}</strong><span>Online</span></div>
|
||||
<div class="vlan-kpi"><strong>${members.length - up}</strong><span>Offline</span></div>
|
||||
<div class="vlan-kpi"><strong>${Object.keys(roles).length}</strong><span>Roles</span></div>
|
||||
</div>
|
||||
${
|
||||
Object.keys(roles).length
|
||||
? `<div class="vlan-role-pills">${Object.entries(roles)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([r, n]) => `<span class="vlan-role-pill">${escape(r)} · ${n}</span>`)
|
||||
.join("")}</div>`
|
||||
: ""
|
||||
}
|
||||
<h4 class="vlan-nodes-h">Server → switch / VLAN map</h4>
|
||||
<div class="vlan-matrix-wrap">
|
||||
<table class="vlan-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<th>Model</th>
|
||||
<th>VLANs</th>
|
||||
<th>Switch / port</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
(matrixFocus.length ? matrixFocus : matrix)
|
||||
.slice(0, 120)
|
||||
.map((row) => {
|
||||
const vlanBits = (row.vlans || [])
|
||||
.map(
|
||||
(v) =>
|
||||
`<span class="vlan-chip" style="--vlan:${escape(v.color || "#888")}">V${v.vlan_id ?? "?"} ${escape(v.name || "")}</span>`
|
||||
)
|
||||
.join(" ");
|
||||
const note = row.user_note || row.endpoint_note || "";
|
||||
return `<tr>
|
||||
<td>
|
||||
<strong>${escape(row.name)}</strong>
|
||||
<div class="muted mono">${escape((row.ips || []).slice(0, 3).join(", "))}${row.service_tag ? " · " + escape(row.service_tag) : ""}</div>
|
||||
</td>
|
||||
<td>${escape(row.model || "—")}</td>
|
||||
<td>${vlanBits || "—"}</td>
|
||||
<td class="mono">${escape(formatSwitchPorts(row.switch_ports))}</td>
|
||||
<td class="vlan-note-cell">${escape(note || "—")}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("") || `<tr><td colspan="5" class="hint">No server attachments yet.</td></tr>`
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4 class="vlan-nodes-h">Add / note a host</h4>
|
||||
<form class="vlan-add-host" id="vlan-add-host">
|
||||
<input name="hostname" placeholder="hostname" required />
|
||||
<input name="ip" class="mono" placeholder="10.0.x.x" required />
|
||||
<input name="model" placeholder="model (optional)" />
|
||||
<input name="user_note" placeholder="manual note" />
|
||||
<button type="submit" class="btn">Add host</button>
|
||||
</form>
|
||||
<h4 class="vlan-nodes-h">Coupled nodes</h4>
|
||||
<div class="vlan-members">
|
||||
${(sel.members || [])
|
||||
.map(
|
||||
(m) => `<div class="vlan-member ${m.connected ? "up" : "down"}">
|
||||
<strong>${escape(m.name)}</strong>
|
||||
<span class="mono">ST=${escape(m.service_tag || "—")} · ${escape(m.role || "")} · ${(m.ips || []).join(", ")}</span>
|
||||
</div>`
|
||||
)
|
||||
.join("") || '<p class="hint">No live members matched this CIDR.</p>'}
|
||||
${
|
||||
members
|
||||
.map((m) => {
|
||||
const rack = rackInfoForDevice(m.id);
|
||||
const links = m.switch_ports?.length ? m.switch_ports : switchLinksForDevice(m.id);
|
||||
const src = m.source === "inventory" ? "inventory" : m.ome_name && m.ome_name !== m.name ? m.ome_name : "";
|
||||
const eid = m.endpoint_id;
|
||||
return `<article class="vlan-member ${m.connected ? "up" : "down"}" data-endpoint-id="${eid || ""}">
|
||||
<div class="vlan-member-top">
|
||||
<strong>${escape(m.name)}</strong>
|
||||
<span class="vlan-badge ${m.connected ? "on" : "off"}">${m.connected ? "online" : "offline"}</span>
|
||||
</div>
|
||||
<div class="vlan-member-grid">
|
||||
<span><em>Role</em> ${escape(m.role || "—")}</span>
|
||||
<span><em>Model</em> ${escape(m.model || "—")}</span>
|
||||
<span><em>Service Tag</em> <span class="mono">${escape(m.service_tag || "—")}</span></span>
|
||||
<span><em>IPs in VLAN</em> <span class="mono">${escape((m.ips || []).join(", ") || "—")}</span></span>
|
||||
<span><em>Rack</em> ${rack ? `${escape(rack.site)} / ${escape(rack.rack)} · ${rack.uLabel}` : "not placed"}</span>
|
||||
<span><em>Switch ports</em> ${escape(formatSwitchPorts(links))}</span>
|
||||
${src ? `<span><em>OME</em> ${escape(src)}</span>` : ""}
|
||||
${m.endpoint_note ? `<span><em>System note</em> ${escape(m.endpoint_note)}</span>` : ""}
|
||||
</div>
|
||||
<div class="vlan-note-edit">
|
||||
<label>Manual note</label>
|
||||
<textarea data-note-for="${eid || ""}" rows="2" placeholder="${eid ? "Type a note and save…" : "Host not yet in inventory — use Add host above"}"${eid ? "" : " disabled"}>${escape(m.user_note || "")}</textarea>
|
||||
<button type="button" class="btn ghost" data-save-note="${eid || ""}" ${eid ? "" : "disabled"}>Save note</button>
|
||||
<span class="vlan-note-status" data-note-status="${eid || ""}"></span>
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("") || '<p class="hint">No live members matched this CIDR yet.</p>'
|
||||
}
|
||||
</div>`
|
||||
: '<p class="hint">No VLANs</p>'
|
||||
}
|
||||
@@ -1431,6 +1746,46 @@
|
||||
renderVlans();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-save-note]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const eid = Number(btn.dataset.saveNote);
|
||||
if (!eid) return;
|
||||
const ta = root.querySelector(`textarea[data-note-for="${eid}"]`);
|
||||
const status = root.querySelector(`[data-note-status="${eid}"]`);
|
||||
try {
|
||||
if (status) status.textContent = "Saving…";
|
||||
const res = await api("PATCH", `/api/network/endpoints/${eid}`, { user_note: ta?.value || "" });
|
||||
if (res.vlans) state.vlans = res.vlans;
|
||||
if (status) status.textContent = "Saved";
|
||||
renderVlans();
|
||||
} catch (e) {
|
||||
if (status) status.textContent = "Error: " + e.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const addForm = root.querySelector("#vlan-add-host");
|
||||
addForm?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(addForm);
|
||||
const body = {
|
||||
hostname: String(fd.get("hostname") || "").trim(),
|
||||
ip: String(fd.get("ip") || "").trim(),
|
||||
model: String(fd.get("model") || "").trim() || null,
|
||||
user_note: String(fd.get("user_note") || "").trim() || null,
|
||||
vlan_id: sel?.vlan_id ?? null,
|
||||
role: "server",
|
||||
kind: "host",
|
||||
};
|
||||
try {
|
||||
const res = await api("POST", "/api/network/endpoints", body);
|
||||
if (res.vlans) state.vlans = res.vlans;
|
||||
renderVlans();
|
||||
} catch (err) {
|
||||
alert("Add host failed: " + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bind() {
|
||||
|
||||
Reference in New Issue
Block a user