(() => {
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,
};
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, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function openNetwork() {
const drawer = $("#network-drawer");
const scrim = $("#scrim");
if (!drawer) return;
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-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 = `
${
sw
? `
DELL
${escape((sw.model || "").replace("Dell EMC Networking ", "").replace(" switch/router", ""))}
${escape(sw.service_tag || "")}
${escape(sw.name)}
${escape(sw.ip || "—")} · ${escape(sw.subnet || "")}
${pm.wired_count}/${sw.port_count} ports wired
${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 ``;
})
.join("")}
${ports
.filter((p) => p.wired && p.link?.device)
.map((p) => {
const d = p.link.device;
return `
P${p.port}
${escape(d.name)}
NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")}
`;
})
.join("") || '
No ports wired yet — click a port on the switch.
'}
`
: `Select a switch.
`
}
`;
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);
requestAnimationFrame(() => drawCables());
}
function renderWirePane(selPort, servers) {
if (!selPort) {
return `Port wiring
Click a port on the switch faceplate to map it to a server NIC / port.
`;
}
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) =>
``
)
.join("");
return `
Wire ${escape(selPort.label)}
Switch port ${selPort.port} → server + NIC/port
${selPort.wired ? `` : ""}
${
link?.device
? `
Current
${escape(link.device.name)}
${escape(link.device_port || "—")} · ${link.device.connected ? "connected" : "offline"}
`
: ""
}
`;
}
async function onWireDeviceChange() {
const id = Number($("#sw-wire-device")?.value || 0);
state.wireDeviceId = id || null;
const list = $("#sw-nic-list");
if (!list || !id) return;
list.innerHTML = "";
try {
const data = await api("GET", `/api/devices/${id}/nics`);
(data.nics || []).forEach((n) => {
const opt = document.createElement("option");
opt.value = n.fqdd || n.name;
opt.label = `${n.name}${n.mac ? " · " + n.mac : ""}`;
list.appendChild(opt);
});
if ((data.nics || [])[0] && !$("#sw-wire-nic")?.value) {
$("#sw-wire-nic").value = data.nics[0].fqdd || data.nics[0].name || "";
}
} catch (_) {
/* ignore */
}
}
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 = 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();
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);
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.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);
ctx.beginPath();
ctx.arc(pt.x, pt.y, live ? 3.2 : 2.2, 0, Math.PI * 2);
ctx.fillStyle = live ? "#ff9a3c" : "#00a8e8";
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 += ``;
}
return ``;
}
if (cat.startsWith("storage")) {
const cols = compact ? 10 : 12;
let bays = "";
for (let i = 0; i < cols; i++) {
bays += ``;
}
return ``;
}
if (cat.includes("chassis") || cat === "chassis_mx") {
const n = compact ? 5 : 8;
return ``;
}
if (cat.startsWith("pdu")) {
return ``;
}
if (cat.startsWith("blank")) {
return ``;
}
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 += ``;
}
return ``;
}
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 `
`;
}
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 `
${productPhoto({ face, image: p.image || p.catalog?.image }, label)}
${escape(code)}
${escape(label)}
${escape(meta)}
`;
}
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(``);
} else {
rows.push(``);
}
}
const fill = rackFillPct(rk);
return ``;
}
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 = `
${["ATC1", "ATC2", "Both"].map((s) => ``).join("")}
${allRacks.map((rk) => miniElevation(rk)).join("") || '
Geen racks
'}
`;
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 = ``;
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 = `
Dell Rack Design Studio
${escape(rk.name)} · ${escape(rk.siteName || "")} · Visio design · drag & drop
${allRacks.map((r) => ``).join("")}
${["front", "rear"].map((s) => ``).join("")}
${Math.round(state.studioZoom * 100)}%
Sleep stencils uit de bibliotheek naar het rack · drop op een U
${escape((state.rackSide || "front").toUpperCase())} view
`;
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 })}
${escape(code)}
${escape(name)}
${uHeight}U · ${escape(face.replace(/_/g, " "))}
`;
try {
e.dataTransfer.setDragImage(ghost, 48, 24);
} catch (_) {}
}
el.classList.add("rs-dragging");
setStatus(`Sleep ${name} (${uHeight}U) — drop op de onderkant-U (belegt omhoog)`);
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(`Verplaatsen (${h}U): drop op nieuwe onderkant-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("Sleep stencils uit de bibliotheek naar het rack · drop op onderkant-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(`Geselecteerd: ${node.dataset.name} — sleep of klik een 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(`Past niet op ${span.label} — overlap of buiten U1–U${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 geblokkeerd — U-bereik niet vrij");
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(`Geplaatst op ${uSpan(u, h).label}`);
} catch (err) {
alert("Drop failed: " + err.message);
setStatus("Drop mislukt — probeer een vrije 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(`Past niet op ${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 ? `Klik → ${uSpan(u, h).label}` : `Past niet op ${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(`Past niet op ${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
? `U${String(uLo).padStart(2, "0")}`
: `
${Array.from({ length: hU }, (_, i) => {
const uu = uHi - i;
return `U${String(uu).padStart(2, "0")}`;
}).join("")}
`;
rows.push(`
${uMarks}
${facePlate(p)}
`);
} else {
rows.push(`
U${String(u).padStart(2, "0")}
drop
`);
}
}
const fill = rackFillPct(rk);
return `
${escape(rk.name)}
${side} · ${fill.used}U used · ${fill.free}U free · scroll for all 42U
${
sideItems.map((s) => `${escape(s.side)}: ${escape(s.label || s.catalog?.name || "PDU")}`).join("") ||
"No side PDUs — place 0U PDU on left/right"
}
`;
}
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 `
${escape(rk.name)}
${items.length} items · ${fill.pct}% full
${
sel
? `
Selected
${escape(sel.label || "")}
${escape(sel.catalog?.name || sel.device?.model || "")}
${uSpan(sel.u_start, sel.u_height).label} · ${escape(sel.side || "front")}
${sel.device?.service_tag ? `
ST ${escape(sel.device.service_tag)}
` : ""}
${sel.device?.ip ? `
${escape(sel.device.ip)}
` : ""}
`
: `Select a device in the rack for details
`
}
Rack contents (U42 → U1)
${
items
.map(
(it) => `
`
)
.join("") || '
Empty — pick from Dell products
'
}
`;
}
/* ===== VLANs ===== */
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];
root.innerHTML = `
${
sel
? `
${(sel.members || [])
.map(
(m) => `
${escape(m.name)}
ST=${escape(m.service_tag || "—")} · ${escape(m.role || "")} · ${(m.ips || []).join(", ")}
`
)
.join("") || '
No live members matched this CIDR.
'}
`
: 'No VLANs
'
}
`;
root.querySelectorAll("[data-vlan]").forEach((btn) => {
btn.addEventListener("click", () => {
state.selectedVlanId = Number(btn.dataset.vlan);
renderVlans();
});
});
}
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 };
})();