Files
ome-cockpit/ui/network.js
T
root b4714c70d1 Add ATC Team chat, soft identity, and update Present decks.
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 01:42:00 +02:00

1810 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const state = {
tab: "fabric",
site: "ATC1",
fabric: null,
racks: null,
vlans: null,
fleet: null,
portmap: null,
selectedSwitchId: null,
selectedPort: null,
selectedRackId: null,
selectedVlanId: null,
placeDeviceId: null,
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) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(url, opts);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function escape(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function openNetwork() {
const drawer = $("#network-drawer");
const scrim = $("#scrim");
if (!drawer) return;
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer", "#team-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "network-drawer";
}
showTab(state.tab);
refresh();
}
function closeNetwork() {
closeStudio();
const drawer = $("#network-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "network-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
stopPulse();
}
function showTab(tab) {
state.tab = tab;
$$("#network-tabs .chip").forEach((c) => c.classList.toggle("active", c.dataset.ntab === tab));
$("#network-fabric")?.classList.toggle("hidden", tab !== "fabric");
$("#network-racks")?.classList.toggle("hidden", tab !== "racks");
$("#network-vlans")?.classList.toggle("hidden", tab !== "vlans");
if (tab === "fabric") startPulse();
else stopPulse();
}
function stopPulse() {
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = 0;
}
function startPulse() {
stopPulse();
const tick = (ts) => {
state.anim = ts / 1000;
drawCables();
state.raf = requestAnimationFrame(tick);
};
state.raf = requestAnimationFrame(tick);
}
async function refresh() {
const mountHint = $("#network-status");
if (mountHint) mountHint.textContent = "Loading…";
try {
const [fleet, fabric, racks, vlans] = await Promise.all([
api("GET", "/api/fleet"),
api("GET", "/api/network/fabric"),
api("GET", "/api/racks"),
api("GET", "/api/network/vlans"),
]);
state.fleet = fleet;
state.fabric = fabric;
state.racks = racks;
state.vlans = vlans;
const switches = (fabric.switches || []).length
? fabric.switches
: (fleet.devices || []).filter((d) => d.is_switch);
if (!state.selectedSwitchId && switches[0]) {
state.selectedSwitchId = switches[0].id;
}
if (state.selectedSwitchId) {
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
}
const wired = state.portmap?.wired_count || 0;
const ports = state.portmap?.switch?.port_count || 0;
if (mountHint) {
mountHint.textContent = `${switches.length} switch · ${wired}/${ports} ports wired · manual port map`;
}
renderFabric();
renderRacks();
renderVlans();
if (state.tab === "fabric") startPulse();
} catch (e) {
if (mountHint) mountHint.textContent = "Error: " + e.message;
}
}
function devices() {
return state.fleet?.devices || [];
}
function switchList() {
const fromFabric = state.fabric?.switches || [];
if (fromFabric.length) return fromFabric;
return devices()
.filter((d) => d.is_switch)
.map((s) => ({
id: s.id,
name: s.name,
service_tag: s.service_tag,
ip: s.ip || s.idrac_ip,
subnet: s.subnet,
model: s.model,
connected_device_ids: [],
}));
}
function renderFabric() {
const root = $("#network-fabric");
if (!root) return;
const switches = switchList();
const pm = state.portmap;
const sw = pm?.switch;
const ports = pm?.ports || [];
const selected = state.selectedPort;
const selPort = ports.find((p) => p.port === selected);
const servers = devices().filter(
(d) => d.is_server || d.is_storage || d.is_chassis || d.is_idrac
);
root.innerHTML = `
<div class="sw-fabric">
<aside class="sw-list-pane">
<h4>Switches</h4>
<div class="sw-cards">
${switches
.map((s) => {
const active = s.id === state.selectedSwitchId ? " active" : "";
return `<button type="button" class="sw-card${active}" data-sw="${s.id}">
<div class="sw-card-ico" aria-hidden="true"></div>
<div class="sw-card-body">
<strong>${escape(s.name)}</strong>
<span>${escape(s.model || "Switch")}</span>
<span class="mono">ST=${escape(s.service_tag || "—")} · ${escape(s.ip || "—")}</span>
</div>
</button>`;
})
.join("") || '<p class="hint">No switches in OME.</p>'}
</div>
<p class="hint">${escape(pm?.note || "Select a switch to map ports.")}</p>
</aside>
<section class="sw-face-pane">
${
sw
? `<div class="sw-faceplate" id="sw-faceplate">
<div class="sw-face-head">
<div class="sw-brand-bar">
<span class="sw-dell">DELL</span>
<span class="sw-model">${escape((sw.model || "").replace("Dell EMC Networking ", "").replace(" switch/router", ""))}</span>
<span class="sw-st mono">${escape(sw.service_tag || "")}</span>
</div>
<div class="sw-face-meta">
<span>${escape(sw.name)}</span>
<span class="mono">${escape(sw.ip || "—")} · ${escape(sw.subnet || "")}</span>
<span class="sw-wired-pill">${pm.wired_count}/${sw.port_count} ports wired</span>
</div>
</div>
<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
.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;
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 class="sw-peer-body">
<strong>${escape(d.name)}</strong>
<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>'}
</div>
</div>
</div>`
: `<p class="hint" style="padding:1rem">Select a switch.</p>`
}
</section>
<aside class="sw-wire-pane" id="sw-wire-pane">
${renderWirePane(selPort, servers)}
</aside>
</div>
`;
root.querySelectorAll("[data-sw]").forEach((btn) => {
btn.addEventListener("click", async () => {
state.selectedSwitchId = Number(btn.dataset.sw);
state.selectedPort = null;
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
renderFabric();
startPulse();
});
});
root.querySelectorAll("[data-port]").forEach((btn) => {
btn.addEventListener("click", () => {
state.selectedPort = Number(btn.dataset.port);
renderFabric();
startPulse();
});
});
$("#sw-wire-save")?.addEventListener("click", saveWire);
$("#sw-wire-clear")?.addEventListener("click", clearWire);
$("#sw-wire-device")?.addEventListener("change", onWireDeviceChange);
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) {
if (!selPort) {
return `<h4>Port wiring</h4><p class="hint">Click a port on the switch faceplate to map it to a server NIC / port.</p>`;
}
const link = selPort.link;
const curDev = link?.device_id || state.wireDeviceId || "";
const curPort = link?.device_port || "";
const opts = servers
.slice()
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
.map(
(d) =>
`<option value="${d.id}" ${String(d.id) === String(curDev) ? "selected" : ""}>${escape(d.name)} · ST=${escape(d.service_tag || "—")}</option>`
)
.join("");
return `
<h4>Wire ${escape(selPort.label)}</h4>
<p class="hint">Switch port <strong>${selPort.port}</strong> → server + NIC/port</p>
<label class="sw-field">Server / endpoint
<select id="sw-wire-device">
<option value="">— select —</option>
${opts}
</select>
</label>
<label class="sw-field">Server port / NIC
<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
<input type="text" id="sw-wire-note" placeholder="optional" value="${escape(link?.note || "")}" />
</label>
<div class="sw-wire-actions">
<button type="button" class="btn primary" id="sw-wire-save">Save link</button>
${selPort.wired ? `<button type="button" class="btn danger" id="sw-wire-clear">Unwire</button>` : ""}
</div>
${
link?.device
? `<div class="sw-wire-current">
<strong>Current</strong>
<span>${escape(link.device.name)}</span>
<span class="mono">${escape(link.device_port || "—")} · ${link.device.connected ? "connected" : "offline"}</span>
</div>`
: ""
}
`;
}
async function onWireDeviceChange() {
const id = Number($("#sw-wire-device")?.value || 0);
state.wireDeviceId = id || null;
const list = $("#sw-nic-list");
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`);
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");
const val = n.port_id || n.fqdd || n.name || "";
opt.value = val;
opt.label = n.label || val;
list.appendChild(opt);
});
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 (_) {
if (pick) pick.innerHTML = `<option value="">NIC lookup failed — type PortId manually</option>`;
}
}
async function saveWire() {
const port = state.selectedPort;
const deviceId = Number($("#sw-wire-device")?.value || 0);
const nic = ($("#sw-wire-nic")?.value || "").trim();
const note = ($("#sw-wire-note")?.value || "").trim();
if (!state.selectedSwitchId || !port) return;
if (!deviceId) {
alert("Select a server / endpoint");
return;
}
try {
state.portmap = await api("PUT", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`, {
switch_port: port,
device_id: deviceId,
device_port: nic,
note,
});
renderFabric();
startPulse();
} catch (e) {
alert("Save failed: " + e.message);
}
}
async function clearWire() {
const port = state.selectedPort;
if (!state.selectedSwitchId || !port) return;
if (!confirm(`Unwire switch port ${port}?`)) return;
try {
state.portmap = await api("DELETE", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`);
renderFabric();
startPulse();
} catch (e) {
alert(e.message);
}
}
function drawCables() {
const canvas = $("#sw-cable-canvas");
const grid = $("#sw-port-grid");
const rail = $("#sw-peer-rail");
const stage = $("#sw-cable-stage") || canvas?.parentElement;
if (!canvas || !grid || !stage || state.tab !== "fabric") return;
const dpr = window.devicePixelRatio || 1;
const w = stage.clientWidth;
const h = stage.clientHeight;
if (w < 10 || h < 10) return;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + "px";
canvas.style.height = h + "px";
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const stageBox = stage.getBoundingClientRect();
const t = state.anim;
const ports = state.portmap?.ports || [];
ports.forEach((p) => {
if (!p.wired) return;
const portBtn = grid.querySelector(`[data-port="${p.port}"]`);
const peer = rail?.querySelector(`[data-peer-port="${p.port}"]`);
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;
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(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();
/* Glow pulse */
const pulse = (Math.sin(t * 5 + p.port) + 1) / 2;
ctx.beginPath();
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();
});
}
function bezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
const u = 1 - t;
return {
x: u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3,
y: u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3,
};
}
/* ===== Dell Rack Design Studio ===== */
const U_OVERVIEW = 7;
const U_STUDIO = 56; // full-bleed faceplate height per U
function roleOf(it) {
return (it.category || it.catalog?.category || it.device?.role || it.face || "custom").toLowerCase();
}
function roleClass(role) {
const r = (role || "").toLowerCase();
if (r.includes("switch")) return "switch";
if (r.includes("storage")) return "storage";
if (r.includes("chassis")) return "chassis";
if (r.includes("pdu")) return "pdu";
if (r.includes("blank")) return "blank";
if (r.includes("server") || r.includes("blade") || r.includes("vxrail")) return "server";
return "custom";
}
/** u_start = bottom U; device fills u_start .. u_start+h-1 (higher U = toward top of rack). */
function uSpan(uStart, h) {
const lo = Math.max(1, Number(uStart) || 1);
const height = Math.max(1, Number(h) || 1);
const hi = lo + height - 1;
return { lo, hi, height, label: height === 1 ? `U${lo}` : `U${lo}U${hi}` };
}
function catalogHeight(sku, fallback = 1) {
const cat = (state.racks?.catalog || []).find((c) => c.sku === sku);
const h = Number(cat?.u_height);
return h > 0 ? h : Math.max(1, Number(fallback) || 1);
}
function unitsFree(rk, uStart, h, excludeItemId) {
const side = state.rackSide || "front";
const units = rk?.units || 42;
const span = uSpan(uStart, h);
if (span.hi > units) return false;
const items = (rk?.items || []).filter((it) => (it.side || "front") === side);
for (const it of items) {
if (excludeItemId != null && (it.id || it.item_id) === excludeItemId) continue;
const a = it.u_start;
const b = it.u_start + Math.max(1, it.u_height || 1) - 1;
if (!(span.hi < a || span.lo > b)) return false;
}
return true;
}
function rackFillPct(rk) {
const items = (rk.items || []).filter((it) => (it.side || "front") === "front");
const used = items.reduce((a, p) => a + Math.max(0, p.u_height || 0), 0);
const units = rk.units || 42;
return { used, free: Math.max(0, units - used), pct: Math.round((used / units) * 100), units };
}
function allRacksList() {
const sites = state.racks?.sites || [];
const show = state.site === "Both" ? sites : sites.filter((s) => s.id === state.site);
return show.flatMap((s) => (s.racks || []).map((r) => ({ ...r, siteName: s.name })));
}
function focusRack() {
const all = allRacksList();
return all.find((r) => r.id === state.selectedRackId) || all[0] || null;
}
let _faceSeq = 0;
function faceSvg(face, label, hU, opts = {}) {
const px = opts.px || U_STUDIO;
const h = Math.max(px, Math.max(1, hU || 1) * px);
const w = opts.w || 420;
const compact = !!opts.compact;
const title = escape((label || "").slice(0, 36));
const gid = "fg" + (++_faceSeq);
const cat = (face || "").toLowerCase();
if (cat.startsWith("switch")) {
const n = compact ? 16 : 24;
let ports = "";
for (let i = 0; i < n; i++) {
const x = 40 + i * ((w - 70) / n);
ports += `<rect x="${x}" y="${h * 0.22}" width="${(w - 70) / n - 1.4}" height="${h * 0.56}" rx="1" fill="#040c12" stroke="#6ecfff" stroke-width="0.7"/>`;
}
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1e5570"/><stop offset="100%" stop-color="#0f3144"/></linearGradient></defs>
<rect width="${w}" height="${h}" rx="3" fill="url(#${gid})"/>
<text x="8" y="${h * 0.62}" fill="#b6e7ff" font-size="${compact ? 6 : 9}" font-family="ui-monospace,monospace">DELL</text>
${ports}<circle cx="${w - 12}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#3dffe0"/>
</svg>`;
}
if (cat.startsWith("storage")) {
const cols = compact ? 10 : 12;
let bays = "";
for (let i = 0; i < cols; i++) {
bays += `<rect x="${10 + i * ((w - 20) / cols)}" y="3" width="${(w - 20) / cols - 1.5}" height="${h - 6}" rx="1.5" fill="#1a1008" stroke="#e08a40" stroke-width="0.7"/>`;
}
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><rect width="${w}" height="${h}" rx="3" fill="#2a180c"/>${bays}</svg>`;
}
if (cat.includes("chassis") || cat === "chassis_mx") {
const n = compact ? 5 : 8;
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<rect width="${w}" height="${h}" rx="3" fill="#1a1430"/>
${Array.from({ length: n }, (_, i) => `<rect x="${8 + i * ((w - 16) / n)}" y="4" width="${(w - 16) / n - 3}" height="${h - 8}" rx="2" fill="#2a2150" stroke="#9b8cff" stroke-width="0.7"/>`).join("")}
</svg>`;
}
if (cat.startsWith("pdu")) {
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<rect width="${w}" height="${h}" rx="2" fill="#2c2c2c"/>
${Array.from({ length: compact ? 8 : 12 }, (_, i) => `<circle cx="${18 + i * ((w - 30) / (compact ? 8 : 12))}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#111" stroke="#bbb"/>`).join("")}
</svg>`;
}
if (cat.startsWith("blank")) {
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><rect width="${w}" height="${h}" rx="2" fill="#141a22" stroke="#2e3742"/></svg>`;
}
const drives = cat.includes("4u") ? 24 : cat.includes("2u") ? 12 : 8;
const n = compact ? Math.min(drives, 10) : drives;
let disks = "";
for (let i = 0; i < n; i++) {
disks += `<rect x="${36 + i * ((w - 56) / n)}" y="${h * 0.16}" width="${(w - 56) / n - 1.5}" height="${h * 0.68}" rx="1" fill="#071820" stroke="#3dffe0" stroke-width="0.55"/>`;
}
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1c4450"/><stop offset="100%" stop-color="#0c2834"/></linearGradient></defs>
<rect width="${w}" height="${h}" rx="3" fill="url(#${gid})"/>
<text x="6" y="${compact ? h * 0.55 : 11}" fill="#9fe" font-size="${compact ? 6 : 8}" font-family="ui-monospace,monospace">PowerEdge</text>
${disks}<circle cx="${w - 10}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#3dffe0"/>
${compact ? "" : `<text x="6" y="${h - 3}" fill="#cfe" font-size="7">${title}</text>`}
</svg>`;
}
function ensureStudioRoot() {
let el = $("#rack-studio");
if (el) return el;
el = document.createElement("div");
el.id = "rack-studio";
el.className = "rack-studio";
el.setAttribute("aria-hidden", "true");
document.body.appendChild(el);
return el;
}
function openStudio(rackId) {
if (rackId != null) state.selectedRackId = rackId;
state.studioOpen = true;
state.studioZoom = state.studioZoom || 1;
state.rackSide = state.rackSide || "front";
state.libTab = state.libTab || "catalog";
state.catalogFamily = state.catalogFamily || "PowerEdge";
renderRacks(); // keep overview in sync
renderStudio();
}
function closeStudio() {
state.studioOpen = false;
state.placeDeviceId = null;
state.placeSku = null;
const el = $("#rack-studio");
if (el) {
if (el._onKey) document.removeEventListener("keydown", el._onKey);
el._onKey = null;
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
el.innerHTML = "";
}
}
function productPhoto(itOrFace, label, opts = {}) {
const img =
(typeof itOrFace === "object" && itOrFace
? itOrFace.image || itOrFace.catalog?.image || null
: null) ||
opts.image ||
null;
const face =
typeof itOrFace === "string"
? itOrFace
: itOrFace?.face || itOrFace?.catalog?.face || "server_1u";
const fallback = {
server_1u: "/assets/dell/face-server-1u.jpg?v=bezel2",
server_2u: "/assets/dell/face-server-2u.jpg?v=bezel2",
server_4u: "/assets/dell/face-server-4u.jpg?v=bezel2",
switch_1u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
switch_2u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
storage_2u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
storage_3u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
chassis_mx: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
pdu_1u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
pdu_0u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
blank_1u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
blank_2u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
blade: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
fabric: "/assets/dell/face-switch-1u.jpg?v=bezel2",
custom: "/assets/dell/face-server-1u.jpg?v=bezel2",
};
const src = img || fallback[face] || fallback.custom;
const cls = opts.compact ? "ru-photo compact" : "ru-photo";
return `<img class="${cls}" src="${escape(src)}" alt="${escape(label || "")}" loading="lazy" draggable="false" />`;
}
function shortModel(name) {
const s = String(name || "");
const m = s.match(/\b(R\d{3,4}\w*|MX\d+\w*|S\d{4}\w*|N\d{4}\w*|Z\d{4}\w*|ME\d{4}|DD\d{4}|T\d{3}|XE\d+|C\d{4}\w*|XR\d+|PC-?\d+|PowerConnect\s*\d+|PowerSwitch\s*\S+|PowerVault\s*\S+|VxRail\s*\S+)\b/i);
if (m) return m[1].replace(/^Power(Edge|Switch|Vault|Connect)\s*/i, "").trim();
return s.replace(/^PowerEdge\s+/i, "").slice(0, 14) || "Device";
}
function tintFor(key) {
let h = 0;
const s = String(key || "");
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
function facePlate(p) {
const face = p.face || p.catalog?.face || "server_1u";
const label = p.label || p.device?.name || p.catalog?.name || "Device";
const model = p.catalog?.name || p.device?.model || label;
const code = shortModel(model);
const hU = p.u_height || 1;
const rc = roleClass(roleOf(p));
const sku = p.catalog_sku || p.catalog?.sku || model;
const hue = tintFor(sku);
const meta = [
hU > 1 ? uSpan(p.u_start, hU).label : null,
hU ? `${hU}U` : null,
p.device?.service_tag ? `ST ${p.device.service_tag}` : null,
p.catalog?.category || p.category || rc,
]
.filter(Boolean)
.join(" · ");
return `<div class="rs-u-face role-${rc}" style="--face-hue:${hue}">
${productPhoto({ face, image: p.image || p.catalog?.image }, label)}
<div class="rs-u-plate">
<span class="rs-u-code">${escape(code)}</span>
<strong>${escape(label)}</strong>
<span>${escape(meta)}</span>
</div>
</div>`;
}
function miniElevation(rk) {
const units = rk.units || 42;
const items = (rk.items || []).filter((it) => (it.side || "front") === "front");
const occ = new Array(units + 1).fill(null);
items.forEach((p) => {
for (let u = p.u_start; u < p.u_start + p.u_height && u <= units; u++) occ[u] = p;
});
const rows = [];
for (let u = units; u >= 1; u--) {
const p = occ[u];
const isStart = p && p.u_start === u;
if (p && !isStart) continue;
if (p && isStart) {
rows.push(`<div class="ov-slot filled role-${roleClass(roleOf(p))}" style="height:${Math.max(3, p.u_height * U_OVERVIEW)}px" title="${escape(p.label || "")} · ${uSpan(p.u_start, p.u_height).label}"></div>`);
} else {
rows.push(`<div class="ov-slot empty" style="height:${U_OVERVIEW}px"></div>`);
}
}
const fill = rackFillPct(rk);
return `<button type="button" class="ov-rack" data-open-studio="${rk.id}">
<div class="ov-rack-top"><strong>${escape(rk.name)}</strong><span>${fill.used}/${fill.units}U · ${fill.pct}%</span></div>
<div class="ov-rack-body">${rows.join("")}</div>
<div class="ov-rack-cta">Open Design Studio</div>
</button>`;
}
function renderRacks() {
const root = $("#network-racks");
if (!root || !state.racks) return;
const allRacks = allRacksList();
if (!state.selectedRackId && allRacks[0]) state.selectedRackId = allRacks[0].id;
root.innerHTML = `
<div class="rd-bar">
<div class="rd-bar-left">
<div class="chip-grid">
${["ATC1", "ATC2", "Both"].map((s) => `<button type="button" class="chip ${state.site === s ? "active" : ""}" data-site="${s}">${s}</button>`).join("")}
</div>
</div>
<div class="rd-bar-right">
<button type="button" class="btn primary" id="rd-open-studio" ${allRacks.length ? "" : "disabled"}>Design Studio</button>
<button type="button" class="btn" id="net-refresh-racks">Refresh</button>
</div>
</div>
<div class="rd-overview">
<header class="rd-overview-head">
<div>
<h3>Rack floor · ${escape(state.site === "Both" ? "ATC1 + ATC2" : state.site)}</h3>
<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>
<span class="lg storage">Storage</span><span class="lg chassis">Chassis</span><span class="lg pdu">PDU</span>
</div>
</header>
<div class="rd-gallery">
${allRacks.map((rk) => miniElevation(rk)).join("") || '<p class="hint">Geen racks</p>'}
</div>
</div>
`;
root.querySelectorAll("[data-site]").forEach((btn) =>
btn.addEventListener("click", () => {
state.site = btn.dataset.site;
state.selectedRackId = null;
renderRacks();
})
);
root.querySelectorAll("[data-open-studio]").forEach((btn) =>
btn.addEventListener("click", () => openStudio(Number(btn.dataset.openStudio)))
);
$("#rd-open-studio")?.addEventListener("click", () => openStudio(state.selectedRackId));
$("#net-refresh-racks")?.addEventListener("click", () => refresh());
if (state.studioOpen) renderStudio();
}
function renderStudio() {
const el = ensureStudioRoot();
const rk = focusRack();
const catalog = state.racks?.catalog || [];
const families = [...new Set(catalog.map((c) => c.family))];
const unplaced = state.racks?.unplaced || [];
const allRacks = allRacksList();
if (!rk) {
el.innerHTML = `<div class="rs-backdrop"></div><div class="rs-panel"><p class="hint">Geen rack</p><button class="btn" id="rs-close">Close</button></div>`;
el.classList.add("open");
$("#rs-close")?.addEventListener("click", closeStudio);
return;
}
state.studioZoom = Math.min(1.8, Math.max(0.7, state.studioZoom || 1.15));
const q = (state.libQuery || "").toLowerCase().trim();
const filteredCat = catalog
.filter((c) => c.family === state.catalogFamily)
.filter((c) => !q || [c.name, c.sku, c.category].join(" ").toLowerCase().includes(q));
const filteredUn = unplaced.filter(
(d) => !q || [d.name, d.model, d.service_tag, d.role, d.ip].join(" ").toLowerCase().includes(q)
);
const placing = !!(state.placeDeviceId || state.placeSku);
const placeLabel = state.placeSku
? catalog.find((c) => c.sku === state.placeSku)?.name || state.placeSku
: unplaced.find((d) => d.id === state.placeDeviceId)?.name || "";
el.innerHTML = `
<div class="rs-backdrop" data-rs-close></div>
<div class="rs-shell" role="dialog" aria-modal="true" aria-label="Dell Rack Design Studio">
<header class="rs-head">
<div class="rs-brand">
<img src="/dell.png" alt="" height="20" />
<div>
<strong>Dell Rack Design Studio</strong>
<span>${escape(rk.name)} · ${escape(rk.siteName || "")} · Visio design · drag &amp; drop</span>
</div>
</div>
<div class="rs-head-actions">
<div class="chip-grid">
${allRacks.map((r) => `<button type="button" class="chip ${r.id === rk.id ? "active" : ""}" data-rs-rack="${r.id}">${escape(r.name)}</button>`).join("")}
</div>
<div class="chip-grid">
${["front", "rear"].map((s) => `<button type="button" class="chip ${state.rackSide === s ? "active" : ""}" data-rs-side="${s}">${s}</button>`).join("")}
</div>
<div class="rs-zoom">
<button type="button" class="btn" data-rs-zoom="-"></button>
<span>${Math.round(state.studioZoom * 100)}%</span>
<button type="button" class="btn" data-rs-zoom="+">+</button>
<button type="button" class="btn" data-rs-zoom="fit">Fit</button>
</div>
<button type="button" class="btn ghost" id="rs-close">Close</button>
</div>
</header>
<div class="rs-body">
<aside class="rs-lib">
<div class="rs-lib-tabs">
<button type="button" class="${state.libTab === "catalog" ? "active" : ""}" data-libtab="catalog">Dell products</button>
<button type="button" class="${state.libTab === "unplaced" ? "active" : ""}" data-libtab="unplaced">OME (${unplaced.length})</button>
</div>
<input type="search" id="rs-lib-q" placeholder="Search SKU / model…" value="${escape(state.libQuery || "")}" />
${
state.libTab === "catalog"
? `<div class="chip-grid rs-fam">${families
.map((f) => `<button type="button" class="chip ${state.catalogFamily === f ? "active" : ""}" data-fam="${escape(f)}">${escape(f)}</button>`)
.join("")}</div>
<div class="rs-lib-list">${filteredCat
.map(
(c) => `<div class="rs-stencil ${state.placeSku === c.sku ? "active" : ""}" draggable="true"
data-sku="${escape(c.sku)}" data-h="${c.u_height > 0 ? c.u_height : 1}" data-face="${escape(c.face || "")}" data-img="${escape(c.image || "")}" data-name="${escape(c.name)}">
<span class="rs-stencil-grip" aria-hidden="true"></span>
<span class="rs-prod-face">${productPhoto(c, c.name, { compact: true })}</span>
<span class="rs-prod-meta"><strong>${escape(shortModel(c.name))}</strong> ${escape(c.name)}<em>${c.u_height || 0}U · ${escape((c.face || "").replace(/_/g, " "))} · drag</em></span>
</div>`
)
.join("") || '<p class="hint">No products</p>'}</div>`
: `<div class="rs-lib-list">${filteredUn
.map(
(d) => `<div class="rs-stencil ${state.placeDeviceId === d.id ? "active" : ""}" draggable="true"
data-place="${d.id}" data-h="${d.default_u_height || 1}" data-face="${escape(d.face || "")}" data-img="${escape(d.image || "")}" data-name="${escape(d.name)}" data-sku="${escape(d.catalog_sku || "")}">
<span class="rs-stencil-grip" aria-hidden="true"></span>
<span class="rs-prod-face">${productPhoto({ face: d.face, image: d.image }, d.name, { compact: true })}</span>
<span class="rs-prod-meta"><strong>${escape(d.name)}</strong><em>${escape(d.model || "")} · ${d.default_u_height}U · drag</em></span>
</div>`
)
.join("") || '<p class="hint">All placed</p>'}</div>`
}
<p class="rs-lib-hint">Visio-stijl: sleep een product naar een vrije U in het rack</p>
</aside>
<main class="rs-canvas-wrap">
<div class="rs-statusbar">
<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">
<div class="rs-zoom-layer" id="rs-zoom-layer" style="transform:scale(${state.studioZoom}); transform-origin: top center;">
${renderStudioRack(rk)}
</div>
</div>
<div id="rs-drag-ghost" class="rs-drag-ghost" hidden></div>
</main>
<aside class="rs-insp">
${renderStudioInspector(rk)}
</aside>
</div>
</div>
`;
el.classList.add("open");
el.setAttribute("aria-hidden", "false");
bindStudio(el, catalog, unplaced);
// scroll so top (U42) is visible; user can scroll full height
const canvas = $("#rs-canvas", el);
if (canvas && state.studioScrollTop != null) canvas.scrollTop = state.studioScrollTop;
}
function bindStudio(el, catalog, unplaced) {
const status = () => $("#rs-status-text", el);
const setStatus = (msg) => {
if (status()) status().textContent = msg;
};
$("#rs-close", el)?.addEventListener("click", closeStudio);
el.querySelector("[data-rs-close]")?.addEventListener("click", closeStudio);
el.querySelectorAll("[data-rs-rack]").forEach((btn) =>
btn.addEventListener("click", () => {
state.selectedRackId = Number(btn.dataset.rsRack);
renderStudio();
})
);
el.querySelectorAll("[data-rs-side]").forEach((btn) =>
btn.addEventListener("click", () => {
state.rackSide = btn.dataset.rsSide;
renderStudio();
})
);
el.querySelectorAll("[data-rs-zoom]").forEach((btn) =>
btn.addEventListener("click", () => {
const z = btn.dataset.rsZoom;
if (z === "+") state.studioZoom = Math.min(1.8, (state.studioZoom || 1) + 0.1);
else if (z === "-") state.studioZoom = Math.max(0.7, (state.studioZoom || 1) - 0.1);
else state.studioZoom = 1;
renderStudio();
})
);
el.querySelectorAll("[data-libtab]").forEach((btn) =>
btn.addEventListener("click", () => {
state.libTab = btn.dataset.libtab;
renderStudio();
})
);
el.querySelectorAll("[data-fam]").forEach((btn) =>
btn.addEventListener("click", () => {
state.catalogFamily = btn.dataset.fam;
renderStudio();
})
);
const qEl = $("#rs-lib-q", el);
qEl?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
state.libQuery = qEl.value;
renderStudio();
}
});
qEl?.addEventListener("change", () => {
state.libQuery = qEl.value;
renderStudio();
});
const canvas = $("#rs-canvas", el);
canvas?.addEventListener("scroll", () => {
state.studioScrollTop = canvas.scrollTop;
});
el.querySelectorAll("[data-item-del]").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation();
e.preventDefault();
if (!confirm("Remove from rack?")) return;
state.racks = await api("DELETE", `/api/racks/items/${Number(btn.dataset.itemDel)}`);
renderRacks();
renderStudio();
})
);
el.querySelectorAll("[data-item-sel]").forEach((btn) =>
btn.addEventListener("click", (e) => {
if (e.target.closest("[data-item-del]")) return;
if (state._dragMoved) return;
state.selectedItemId = Number(btn.dataset.itemSel);
renderStudio();
})
);
/* ---- Visio-style drag & drop ---- */
const ghost = $("#rs-drag-ghost", el);
let dragPayload = null;
function clearDropHighlight() {
el.querySelectorAll(".rs-u.drop-over, .rs-u.drop-span, .rs-u.drop-bad").forEach((n) => {
n.classList.remove("drop-over", "drop-span", "drop-bad");
});
}
function highlightSpan(uStart, h, ok) {
clearDropHighlight();
const span = uSpan(uStart, h);
for (let u = span.lo; u <= span.hi; u++) {
const zone = el.querySelector(`.rs-u.empty[data-u="${u}"]`);
if (!zone) continue;
zone.classList.add(ok ? "drop-span" : "drop-bad");
if (u === uStart) zone.classList.add("drop-over");
}
el.classList.add("rs-dragging");
return span;
}
function parseDrag(raw) {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveHeight(payload) {
if (!payload) return state.placeH || 1;
if (payload.sku) return catalogHeight(payload.sku, payload.u_height || 1);
return Math.max(1, Number(payload.u_height) || state.placeH || 1);
}
function stencilDragStart(node, e) {
const sku = node.dataset.sku || null;
const deviceId = node.dataset.place ? Number(node.dataset.place) : null;
const h = Number(node.dataset.h || 1);
const catHit = (sku && catalog.find((c) => c.sku === sku)) || null;
const unHit = deviceId ? unplaced.find((d) => d.id === deviceId) : null;
const name = node.dataset.name || catHit?.name || unHit?.name || "Device";
const face = node.dataset.face || catHit?.face || unHit?.face || "server_1u";
const image = node.dataset.img || catHit?.image || unHit?.image || "";
const uHeight = sku
? catalogHeight(sku, h)
: h > 0
? h
: catHit?.u_height > 0
? catHit.u_height
: 1;
dragPayload = {
kind: "stencil",
sku: sku || null,
device_id: deviceId,
u_height: uHeight,
name,
image,
face,
};
state.placeSku = sku || null;
state.placeDeviceId = deviceId;
state.placeH = dragPayload.u_height;
e.dataTransfer.effectAllowed = "copy";
const raw = JSON.stringify(dragPayload);
e.dataTransfer.setData("application/x-rack-item", raw);
e.dataTransfer.setData("text/plain", raw);
if (ghost) {
ghost.hidden = false;
const code = shortModel(name);
ghost.innerHTML = `${productPhoto({ face, image }, name, { compact: true })}
<span class="rs-ghost-meta">
<span class="rs-ghost-code">${escape(code)}</span>
<strong>${escape(name)}</strong>
<span>${uHeight}U · ${escape(face.replace(/_/g, " "))}</span>
</span>`;
try {
e.dataTransfer.setDragImage(ghost, 48, 24);
} catch (_) {}
}
el.classList.add("rs-dragging");
setStatus(`Dragging ${name} (${uHeight}U) — drop on the bottom U (fills upward)`);
node.classList.add("dragging");
}
function itemDragStart(node, e) {
if (e.target.closest("[data-item-del]")) {
e.preventDefault();
return;
}
state._dragMoved = false;
const h = Math.max(1, Number(node.dataset.h || 1));
dragPayload = {
kind: "move",
item_id: Number(node.dataset.itemId),
u_height: h,
name: node.querySelector(".rs-u-plate strong")?.textContent || node.querySelector("strong")?.textContent || "Device",
};
e.dataTransfer.effectAllowed = "move";
const raw = JSON.stringify(dragPayload);
e.dataTransfer.setData("application/x-rack-item", raw);
e.dataTransfer.setData("text/plain", raw);
el.classList.add("rs-dragging");
setStatus(`Moving (${h}U): drop on a new bottom U`);
node.classList.add("dragging");
}
el.querySelectorAll(".rs-stencil").forEach((node) => {
node.addEventListener("dragstart", (e) => stencilDragStart(node, e));
node.addEventListener("dragend", () => {
node.classList.remove("dragging");
clearDropHighlight();
el.classList.remove("rs-dragging");
if (ghost) ghost.hidden = true;
dragPayload = null;
setStatus("Drag stencils from the library onto a bottom U");
});
// click still selects for keyboard users
node.addEventListener("click", () => {
if (node.dataset.sku) {
state.placeSku = node.dataset.sku;
state.placeDeviceId = null;
state.placeH = Number(node.dataset.h || 1);
} else if (node.dataset.place) {
state.placeDeviceId = Number(node.dataset.place);
state.placeSku = node.dataset.sku || null;
state.placeH = Number(node.dataset.h || 1);
}
setStatus(`Selected: ${node.dataset.name} — drag or click a U`);
el.querySelectorAll(".rs-stencil").forEach((n) => n.classList.toggle("active", n === node));
});
});
el.querySelectorAll(".rs-u.filled").forEach((node) => {
node.addEventListener("dragstart", (e) => itemDragStart(node, e));
node.addEventListener("dragend", () => {
node.classList.remove("dragging");
clearDropHighlight();
el.classList.remove("rs-dragging");
dragPayload = null;
setTimeout(() => {
state._dragMoved = false;
}, 0);
});
});
el.querySelectorAll("[data-drop-u]").forEach((zone) => {
zone.addEventListener("dragover", (e) => {
e.preventDefault();
const rk = focusRack();
const u = Number(zone.dataset.u);
const h = resolveHeight(dragPayload);
const exclude = dragPayload?.kind === "move" ? dragPayload.item_id : null;
const ok = unitsFree(rk, u, h, exclude);
e.dataTransfer.dropEffect = ok ? (dragPayload?.kind === "move" ? "move" : "copy") : "none";
const span = highlightSpan(u, h, ok);
if (ok) {
setStatus(`Drop → ${span.label} (${span.height}U)`);
} else {
setStatus(`Does not fit on ${span.label} — overlap or outside U1U${rk?.units || 42}`);
}
});
zone.addEventListener("dragleave", (e) => {
if (!zone.contains(e.relatedTarget)) {
zone.classList.remove("drop-over", "drop-span", "drop-bad");
}
});
zone.addEventListener("drop", async (e) => {
e.preventDefault();
e.stopPropagation();
const raw =
e.dataTransfer.getData("application/x-rack-item") ||
e.dataTransfer.getData("text/plain");
const payload = parseDrag(raw) || dragPayload;
clearDropHighlight();
el.classList.remove("rs-dragging");
if (!payload) return;
const u = Number(zone.dataset.u);
const h = resolveHeight(payload);
const rk = focusRack();
const exclude = payload.kind === "move" ? payload.item_id : null;
if (!unitsFree(rk, u, h, exclude)) {
setStatus("Drop blocked — U range is not free");
return;
}
state._dragMoved = true;
try {
if (payload.kind === "move" && payload.item_id) {
state.racks = await api("PATCH", `/api/racks/items/${payload.item_id}`, {
u_start: u,
u_height: h,
side: state.rackSide || "front",
});
} else {
state.placeSku = payload.sku || null;
state.placeDeviceId = payload.device_id || null;
state.placeH = h;
state.placeU = u;
state.racks = await api("POST", "/api/racks/items", {
rack_id: state.selectedRackId,
u_start: u,
u_height: h,
side: state.rackSide || "front",
device_id: payload.device_id || null,
catalog_sku: payload.sku || null,
});
state.placeSku = null;
state.placeDeviceId = null;
}
renderRacks();
renderStudio();
setStatus(`Placed on ${uSpan(u, h).label}`);
} catch (err) {
alert("Drop failed: " + err.message);
setStatus("Drop failed — try a free U");
}
});
});
// click-to-place still works when stencil preselected
el.querySelectorAll(".rs-u.empty").forEach((node) => {
node.addEventListener("click", () => {
if (!state.placeDeviceId && !state.placeSku) return;
const u = Number(node.dataset.u);
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(`Does not fit on ${uSpan(u, h).label}`);
return;
}
state.placeU = u;
state.placeH = h;
placeCurrent(true);
});
node.addEventListener("mouseenter", () => {
if (!state.placeDeviceId && !state.placeSku) return;
if (dragPayload) return;
const u = Number(node.dataset.u);
const h = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, state.placeH || 1);
const rk = focusRack();
const ok = unitsFree(rk, u, h, null);
highlightSpan(u, h, ok);
setStatus(ok ? `Click → ${uSpan(u, h).label}` : `Does not fit on ${uSpan(u, h).label}`);
});
node.addEventListener("mouseleave", () => {
if (!dragPayload) clearDropHighlight();
});
});
if (el._onKey) document.removeEventListener("keydown", el._onKey);
el._onKey = (ev) => {
if (ev.key === "Escape") closeStudio();
};
document.addEventListener("keydown", el._onKey);
}
async function placeCurrent(fromStudio) {
if (!state.selectedRackId) return;
const uStart = Number(state.placeU || 1);
const height = state.placeSku
? catalogHeight(state.placeSku, state.placeH || 1)
: Math.max(1, Number(state.placeH || 1));
const side = state.rackSide || "front";
const rk = focusRack();
if (!unitsFree(rk, uStart, height, null)) {
alert(`Does not fit on ${uSpan(uStart, height).label}`);
return;
}
try {
state.racks = await api("POST", "/api/racks/items", {
rack_id: state.selectedRackId,
u_start: uStart,
u_height: height,
side,
device_id: state.placeDeviceId || null,
catalog_sku: state.placeSku || null,
});
state.placeDeviceId = null;
state.placeSku = null;
renderRacks();
if (state.studioOpen) renderStudio();
} catch (err) {
alert("Place failed: " + err.message);
}
}
function renderStudioRack(rk) {
const units = rk.units || 42;
const side = state.rackSide || "front";
const items = (rk.items || []).filter((it) => (it.side || "front") === side);
const sideItems = (rk.items || []).filter((it) => it.side === "left" || it.side === "right");
const occ = new Array(units + 1).fill(null);
items.forEach((p) => {
for (let u = p.u_start; u < p.u_start + p.u_height && u <= units; u++) occ[u] = p;
});
const rows = [];
for (let u = units; u >= 1; u--) {
const p = occ[u];
const isStart = p && p.u_start === u;
if (p && !isStart) continue;
if (p && isStart) {
const hU = Math.max(1, Number(p.u_height) || 1);
const h = hU * U_STUDIO;
const rc = roleClass(roleOf(p));
const selected = state.selectedItemId === (p.id || p.item_id);
const uLo = p.u_start;
const uHi = p.u_start + hU - 1;
/* Rail labels: high U at top (rack reads top→bottom) */
const uMarks =
hU === 1
? `<span class="rs-u-num">U${String(uLo).padStart(2, "0")}</span>`
: `<span class="rs-u-nums" title="U${uLo}U${uHi} (${hU}U)">
${Array.from({ length: hU }, (_, i) => {
const uu = uHi - i;
return `<span class="rs-u-num">U${String(uu).padStart(2, "0")}</span>`;
}).join("")}
</span>`;
rows.push(`<div class="rs-u filled role-${rc} ${selected ? "sel" : ""}" draggable="true"
data-u="${u}" data-item-sel="${p.id || p.item_id}" data-item-id="${p.id || p.item_id}"
data-h="${hU}" style="height:${h}px" title="U${uLo}U${uHi} · Drag to move · click to select">
${uMarks}
${facePlate(p)}
<button type="button" class="rs-u-x" data-item-del="${p.id || p.item_id}" title="Remove">×</button>
</div>`);
} else {
rows.push(`<div class="rs-u empty drop-zone" data-u="${u}" data-drop-u="1" style="height:${U_STUDIO}px">
<span class="rs-u-num">U${String(u).padStart(2, "0")}</span>
<span class="rs-u-ghost">drop</span>
</div>`);
}
}
const fill = rackFillPct(rk);
return `<div class="rs-cabinet">
<div class="rs-cab-head">
<div>
<strong>${escape(rk.name)}</strong>
<span>${side} · ${fill.used}U used · ${fill.free}U free · scroll for all 42U</span>
</div>
<div class="rs-side-pdu">${
sideItems.map((s) => `<span>${escape(s.side)}: ${escape(s.label || s.catalog?.name || "PDU")}</span>`).join("") ||
"<span class='muted'>No side PDUs — place 0U PDU on left/right</span>"
}</div>
</div>
<div class="rs-frame">
<div class="rs-rail" aria-hidden="true"></div>
<div class="rs-stack">${rows.join("")}</div>
<div class="rs-rail" aria-hidden="true"></div>
</div>
</div>`;
}
function renderStudioInspector(rk) {
const side = state.rackSide || "front";
const items = [...(rk.items || [])]
.filter((it) => (it.side || "front") === side)
.sort((a, b) => b.u_start - a.u_start);
const fill = rackFillPct(rk);
const sel = items.find((i) => (i.id || i.item_id) === state.selectedItemId);
return `
<div class="rs-insp-head">
<strong>${escape(rk.name)}</strong>
<span>${items.length} items · ${fill.pct}% full</span>
</div>
<div class="rd-fillbar"><i style="width:${fill.pct}%"></i></div>
${
sel
? `<div class="rd-insp-card">
<h4>Selected</h4>
<strong>${escape(sel.label || "")}</strong>
<p>${escape(sel.catalog?.name || sel.device?.model || "")}</p>
<p class="mono">${uSpan(sel.u_start, sel.u_height).label} · ${escape(sel.side || "front")}</p>
${sel.device?.service_tag ? `<p class="mono">ST ${escape(sel.device.service_tag)}</p>` : ""}
${sel.device?.ip ? `<p class="mono">${escape(sel.device.ip)}</p>` : ""}
<button type="button" class="btn" data-item-del="${sel.id || sel.item_id}" style="margin-top:0.4rem">Remove</button>
</div>`
: `<p class="hint">Select a device in the rack for details</p>`
}
<h4 class="rd-insp-list-h">Rack contents (U42 → U1)</h4>
<div class="rd-insp-list">
${
items
.map(
(it) => `<button type="button" class="rd-insp-row ${state.selectedItemId === (it.id || it.item_id) ? "active" : ""}" data-item-sel="${it.id || it.item_id}">
<span class="rd-lib-swatch role-${roleClass(roleOf(it))}"></span>
<span><strong>${uSpan(it.u_start, it.u_height).label}</strong> ${escape(it.label || "")}</span>
</button>`
)
.join("") || '<p class="hint">Empty — pick from Dell products</p>'
}
</div>
`;
}
/* ===== 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">
<aside class="vlan-list-pane">
<h4>VLAN / subnet catalog</h4>
<p class="hint">${escape(state.vlans.note || "")}</p>
<div class="vlan-cards">
${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 || "#ff9a3c")}">
<span class="vlan-id">VLAN ${v.vlan_id ?? "—"}</span>
<strong>${escape(v.name)}</strong>
<span class="mono">${escape(v.cidr)}</span>
<span class="vlan-card-meta">${v.member_count} nodes · ${v.connected_count} up · ${escape(v.site_id || "—")}</span>
</button>`;
})
.join("")}
</div>
${(state.vlans.discovered_subnets || []).length
? `<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} device IPs seen</li>`)
.join("")}
</ul>`
: ""}
</aside>
<section class="vlan-detail-pane">
${
sel
? `<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)}</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>
</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">
${
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>'
}
</section>
</div>
`;
root.querySelectorAll("[data-vlan]").forEach((btn) => {
btn.addEventListener("click", () => {
state.selectedVlanId = Number(btn.dataset.vlan);
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() {
$("#btn-network")?.addEventListener("click", openNetwork);
$("#btn-network-close")?.addEventListener("click", closeNetwork);
$("#network-tabs")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-ntab]");
if (!chip) return;
showTab(chip.dataset.ntab);
});
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "network-drawer") closeNetwork();
});
window.addEventListener("resize", () => {
if (state.tab === "fabric") drawCables();
});
}
bind();
window.cockpitNetwork = { open: openNetwork, close: closeNetwork, refresh };
})();