Files
ome-cockpit/ui/network.js
T
root 91222faffe Add Network Design Studio with multi-U Dell rack designer.
Includes rack catalog/API, Visio-style drag-and-drop, faceplate assets, readable nameplates, and theme/RDP UI updates so multi-U placement stays correct during design.

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

1455 lines
58 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,
};
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"].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-port-grid" id="sw-port-grid">
${ports
.map((p) => {
const cls = [
"sw-port",
p.wired ? "wired" : "empty",
selected === p.port ? "selected" : "",
p.link?.device?.connected ? "live" : "",
]
.filter(Boolean)
.join(" ");
const tip = p.wired
? `${p.label}${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}`
: `${p.label} — click to wire`;
return `<button type="button" class="${cls}" data-port="${p.port}" title="${escape(tip)}">
<span class="sw-port-num">${p.port}</span>
${p.wired ? '<span class="sw-port-dot"></span>' : ""}
</button>`;
})
.join("")}
</div>
<div class="sw-cable-stage">
<canvas id="sw-cable-canvas"></canvas>
<div class="sw-peer-rail" id="sw-peer-rail">
${ports
.filter((p) => p.wired && p.link?.device)
.map((p) => {
const d = p.link.device;
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" data-peer-port="${p.port}">
<span class="sw-peer-port">P${p.port}</span>
<div>
<strong>${escape(d.name)}</strong>
<span class="mono">NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")}</span>
</div>
</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);
requestAnimationFrame(() => drawCables());
}
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
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="e.g. NIC.Slot.1-1 / eth0 / Port 2" 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");
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 += `<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>Klik een rack om de Design Studio te openen (inzoomen, alle 42U, Dell catalogus).</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">Sleep stencils uit de bibliotheek naar het rack · drop op een 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(`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 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 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
? `<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 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 = `
<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 || "#00a8e8")}">
<span class="vlan-id">VLAN ${v.vlan_id ?? "—"}</span>
<strong>${escape(v.name)}</strong>
<span class="mono">${escape(v.cidr)}</span>
<span>${v.member_count} endpoints · ${v.connected_count} connected</span>
</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} IPs</li>`)
.join("")}
</ul>`
: ""}
</aside>
<section class="vlan-detail-pane">
${
sel
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#00a8e8")}">
<div>
<h3>VLAN ${sel.vlan_id ?? "—"} · ${escape(sel.name)}</h3>
<p class="mono">${escape(sel.cidr)} · ${escape(sel.site_id || "—")} · ${escape(sel.purpose || "")}</p>
</div>
<span class="vlan-stat">${sel.member_count} members</span>
</header>
<div class="vlan-members">
${(sel.members || [])
.map(
(m) => `<div class="vlan-member ${m.connected ? "up" : "down"}">
<strong>${escape(m.name)}</strong>
<span class="mono">ST=${escape(m.service_tag || "—")} · ${escape(m.role || "")} · ${(m.ips || []).join(", ")}</span>
</div>`
)
.join("") || '<p class="hint">No live members matched this CIDR.</p>'}
</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();
});
});
}
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 };
})();