Files

1810 lines
74 KiB
JavaScript
Raw Permalink Normal View History

(() => {
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, "&")
.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 };
})();