(() => {
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, ">")
.replace(/"/g, """);
}
function openNetwork() {
const drawer = $("#network-drawer");
const scrim = $("#scrim");
if (!drawer) return;
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "network-drawer";
}
showTab(state.tab);
refresh();
}
function closeNetwork() {
closeStudio();
const drawer = $("#network-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "network-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
stopPulse();
}
function showTab(tab) {
state.tab = tab;
$$("#network-tabs .chip").forEach((c) => c.classList.toggle("active", c.dataset.ntab === tab));
$("#network-fabric")?.classList.toggle("hidden", tab !== "fabric");
$("#network-racks")?.classList.toggle("hidden", tab !== "racks");
$("#network-vlans")?.classList.toggle("hidden", tab !== "vlans");
if (tab === "fabric") startPulse();
else stopPulse();
}
function stopPulse() {
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = 0;
}
function startPulse() {
stopPulse();
const tick = (ts) => {
state.anim = ts / 1000;
drawCables();
state.raf = requestAnimationFrame(tick);
};
state.raf = requestAnimationFrame(tick);
}
async function refresh() {
const mountHint = $("#network-status");
if (mountHint) mountHint.textContent = "Loading…";
try {
const [fleet, fabric, racks, vlans] = await Promise.all([
api("GET", "/api/fleet"),
api("GET", "/api/network/fabric"),
api("GET", "/api/racks"),
api("GET", "/api/network/vlans"),
]);
state.fleet = fleet;
state.fabric = fabric;
state.racks = racks;
state.vlans = vlans;
const switches = (fabric.switches || []).length
? fabric.switches
: (fleet.devices || []).filter((d) => d.is_switch);
if (!state.selectedSwitchId && switches[0]) {
state.selectedSwitchId = switches[0].id;
}
if (state.selectedSwitchId) {
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
}
const wired = state.portmap?.wired_count || 0;
const ports = state.portmap?.switch?.port_count || 0;
if (mountHint) {
mountHint.textContent = `${switches.length} switch · ${wired}/${ports} ports wired · manual port map`;
}
renderFabric();
renderRacks();
renderVlans();
if (state.tab === "fabric") startPulse();
} catch (e) {
if (mountHint) mountHint.textContent = "Error: " + e.message;
}
}
function devices() {
return state.fleet?.devices || [];
}
function switchList() {
const fromFabric = state.fabric?.switches || [];
if (fromFabric.length) return fromFabric;
return devices()
.filter((d) => d.is_switch)
.map((s) => ({
id: s.id,
name: s.name,
service_tag: s.service_tag,
ip: s.ip || s.idrac_ip,
subnet: s.subnet,
model: s.model,
connected_device_ids: [],
}));
}
function renderFabric() {
const root = $("#network-fabric");
if (!root) return;
const switches = switchList();
const pm = state.portmap;
const sw = pm?.switch;
const ports = pm?.ports || [];
const selected = state.selectedPort;
const selPort = ports.find((p) => p.port === selected);
const servers = devices().filter(
(d) => d.is_server || d.is_storage || d.is_chassis || d.is_idrac
);
root.innerHTML = `
${
sw
? `
DELL
${escape((sw.model || "").replace("Dell EMC Networking ", "").replace(" switch/router", ""))}
${escape(sw.service_tag || "")}
${escape(sw.name)}
${escape(sw.ip || "—")} · ${escape(sw.subnet || "")}
${pm.wired_count}/${sw.port_count} ports wired
${ports
.map((p) => {
const cls = [
"sw-port",
p.wired ? "wired" : "empty",
selected === p.port ? "selected" : "",
p.link?.device?.connected ? "live" : "",
]
.filter(Boolean)
.join(" ");
const tip = p.wired
? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}`
: `${p.label} — click to wire`;
return ``;
})
.join("")}
Drag servers to rearrange · pick cable color per link
${orderedWiredPorts(ports)
.map((p) => {
const d = p.link.device;
const col = getCableColor(p.port);
const nicLabel = p.link.device_port || "—";
return `
`;
})
.join("") || '
No ports wired yet — click a port on the switch.
'}
`
: `Select a switch.
`
}
`;
root.querySelectorAll("[data-sw]").forEach((btn) => {
btn.addEventListener("click", async () => {
state.selectedSwitchId = Number(btn.dataset.sw);
state.selectedPort = null;
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
renderFabric();
startPulse();
});
});
root.querySelectorAll("[data-port]").forEach((btn) => {
btn.addEventListener("click", () => {
state.selectedPort = Number(btn.dataset.port);
renderFabric();
startPulse();
});
});
$("#sw-wire-save")?.addEventListener("click", saveWire);
$("#sw-wire-clear")?.addEventListener("click", clearWire);
$("#sw-wire-device")?.addEventListener("change", onWireDeviceChange);
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 `Port wiring
Click a port on the switch faceplate to map it to a server NIC / port.
`;
}
const link = selPort.link;
const curDev = link?.device_id || state.wireDeviceId || "";
const curPort = link?.device_port || "";
const opts = servers
.slice()
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
.map(
(d) =>
``
)
.join("");
return `
Wire ${escape(selPort.label)}
Switch port ${selPort.port} → server + NIC/port
${selPort.wired ? `` : ""}
${
link?.device
? `
Current
${escape(link.device.name)}
${escape(link.device_port || "—")} · ${link.device.connected ? "connected" : "offline"}
`
: ""
}
`;
}
async function onWireDeviceChange() {
const id = Number($("#sw-wire-device")?.value || 0);
state.wireDeviceId = id || null;
const list = $("#sw-nic-list");
const pick = $("#sw-wire-nic-pick");
const input = $("#sw-wire-nic");
if (list) list.innerHTML = "";
if (pick) {
pick.innerHTML = id
? ``
: ``;
}
if (!id) return;
try {
const data = await api("GET", `/api/devices/${id}/nics`);
const rows = data.nics || [];
if (pick) {
pick.innerHTML =
`` +
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 ``;
})
.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 = ``;
}
}
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 += ``;
}
return ``;
}
if (cat.startsWith("storage")) {
const cols = compact ? 10 : 12;
let bays = "";
for (let i = 0; i < cols; i++) {
bays += ``;
}
return ``;
}
if (cat.includes("chassis") || cat === "chassis_mx") {
const n = compact ? 5 : 8;
return ``;
}
if (cat.startsWith("pdu")) {
return ``;
}
if (cat.startsWith("blank")) {
return ``;
}
const drives = cat.includes("4u") ? 24 : cat.includes("2u") ? 12 : 8;
const n = compact ? Math.min(drives, 10) : drives;
let disks = "";
for (let i = 0; i < n; i++) {
disks += ``;
}
return ``;
}
function ensureStudioRoot() {
let el = $("#rack-studio");
if (el) return el;
el = document.createElement("div");
el.id = "rack-studio";
el.className = "rack-studio";
el.setAttribute("aria-hidden", "true");
document.body.appendChild(el);
return el;
}
function openStudio(rackId) {
if (rackId != null) state.selectedRackId = rackId;
state.studioOpen = true;
state.studioZoom = state.studioZoom || 1;
state.rackSide = state.rackSide || "front";
state.libTab = state.libTab || "catalog";
state.catalogFamily = state.catalogFamily || "PowerEdge";
renderRacks(); // keep overview in sync
renderStudio();
}
function closeStudio() {
state.studioOpen = false;
state.placeDeviceId = null;
state.placeSku = null;
const el = $("#rack-studio");
if (el) {
if (el._onKey) document.removeEventListener("keydown", el._onKey);
el._onKey = null;
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
el.innerHTML = "";
}
}
function productPhoto(itOrFace, label, opts = {}) {
const img =
(typeof itOrFace === "object" && itOrFace
? itOrFace.image || itOrFace.catalog?.image || null
: null) ||
opts.image ||
null;
const face =
typeof itOrFace === "string"
? itOrFace
: itOrFace?.face || itOrFace?.catalog?.face || "server_1u";
const fallback = {
server_1u: "/assets/dell/face-server-1u.jpg?v=bezel2",
server_2u: "/assets/dell/face-server-2u.jpg?v=bezel2",
server_4u: "/assets/dell/face-server-4u.jpg?v=bezel2",
switch_1u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
switch_2u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
storage_2u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
storage_3u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
chassis_mx: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
pdu_1u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
pdu_0u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
blank_1u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
blank_2u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
blade: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
fabric: "/assets/dell/face-switch-1u.jpg?v=bezel2",
custom: "/assets/dell/face-server-1u.jpg?v=bezel2",
};
const src = img || fallback[face] || fallback.custom;
const cls = opts.compact ? "ru-photo compact" : "ru-photo";
return `
`;
}
function shortModel(name) {
const s = String(name || "");
const m = s.match(/\b(R\d{3,4}\w*|MX\d+\w*|S\d{4}\w*|N\d{4}\w*|Z\d{4}\w*|ME\d{4}|DD\d{4}|T\d{3}|XE\d+|C\d{4}\w*|XR\d+|PC-?\d+|PowerConnect\s*\d+|PowerSwitch\s*\S+|PowerVault\s*\S+|VxRail\s*\S+)\b/i);
if (m) return m[1].replace(/^Power(Edge|Switch|Vault|Connect)\s*/i, "").trim();
return s.replace(/^PowerEdge\s+/i, "").slice(0, 14) || "Device";
}
function tintFor(key) {
let h = 0;
const s = String(key || "");
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
function facePlate(p) {
const face = p.face || p.catalog?.face || "server_1u";
const label = p.label || p.device?.name || p.catalog?.name || "Device";
const model = p.catalog?.name || p.device?.model || label;
const code = shortModel(model);
const hU = p.u_height || 1;
const rc = roleClass(roleOf(p));
const sku = p.catalog_sku || p.catalog?.sku || model;
const hue = tintFor(sku);
const meta = [
hU > 1 ? uSpan(p.u_start, hU).label : null,
hU ? `${hU}U` : null,
p.device?.service_tag ? `ST ${p.device.service_tag}` : null,
p.catalog?.category || p.category || rc,
]
.filter(Boolean)
.join(" · ");
return `
${productPhoto({ face, image: p.image || p.catalog?.image }, label)}
${escape(code)}
${escape(label)}
${escape(meta)}
`;
}
function miniElevation(rk) {
const units = rk.units || 42;
const items = (rk.items || []).filter((it) => (it.side || "front") === "front");
const occ = new Array(units + 1).fill(null);
items.forEach((p) => {
for (let u = p.u_start; u < p.u_start + p.u_height && u <= units; u++) occ[u] = p;
});
const rows = [];
for (let u = units; u >= 1; u--) {
const p = occ[u];
const isStart = p && p.u_start === u;
if (p && !isStart) continue;
if (p && isStart) {
rows.push(``);
} else {
rows.push(``);
}
}
const fill = rackFillPct(rk);
return ``;
}
function renderRacks() {
const root = $("#network-racks");
if (!root || !state.racks) return;
const allRacks = allRacksList();
if (!state.selectedRackId && allRacks[0]) state.selectedRackId = allRacks[0].id;
root.innerHTML = `
${["ATC1", "ATC2", "Both"].map((s) => ``).join("")}
${allRacks.map((rk) => miniElevation(rk)).join("") || '
Geen racks
'}
`;
root.querySelectorAll("[data-site]").forEach((btn) =>
btn.addEventListener("click", () => {
state.site = btn.dataset.site;
state.selectedRackId = null;
renderRacks();
})
);
root.querySelectorAll("[data-open-studio]").forEach((btn) =>
btn.addEventListener("click", () => openStudio(Number(btn.dataset.openStudio)))
);
$("#rd-open-studio")?.addEventListener("click", () => openStudio(state.selectedRackId));
$("#net-refresh-racks")?.addEventListener("click", () => refresh());
if (state.studioOpen) renderStudio();
}
function renderStudio() {
const el = ensureStudioRoot();
const rk = focusRack();
const catalog = state.racks?.catalog || [];
const families = [...new Set(catalog.map((c) => c.family))];
const unplaced = state.racks?.unplaced || [];
const allRacks = allRacksList();
if (!rk) {
el.innerHTML = ``;
el.classList.add("open");
$("#rs-close")?.addEventListener("click", closeStudio);
return;
}
state.studioZoom = Math.min(1.8, Math.max(0.7, state.studioZoom || 1.15));
const q = (state.libQuery || "").toLowerCase().trim();
const filteredCat = catalog
.filter((c) => c.family === state.catalogFamily)
.filter((c) => !q || [c.name, c.sku, c.category].join(" ").toLowerCase().includes(q));
const filteredUn = unplaced.filter(
(d) => !q || [d.name, d.model, d.service_tag, d.role, d.ip].join(" ").toLowerCase().includes(q)
);
const placing = !!(state.placeDeviceId || state.placeSku);
const placeLabel = state.placeSku
? catalog.find((c) => c.sku === state.placeSku)?.name || state.placeSku
: unplaced.find((d) => d.id === state.placeDeviceId)?.name || "";
el.innerHTML = `
Dell Rack Design Studio
${escape(rk.name)} · ${escape(rk.siteName || "")} · Visio design · drag & drop
${allRacks.map((r) => ``).join("")}
${["front", "rear"].map((s) => ``).join("")}
${Math.round(state.studioZoom * 100)}%
Drag stencils from the library onto a U
${escape((state.rackSide || "front").toUpperCase())} view
`;
el.classList.add("open");
el.setAttribute("aria-hidden", "false");
bindStudio(el, catalog, unplaced);
// scroll so top (U42) is visible; user can scroll full height
const canvas = $("#rs-canvas", el);
if (canvas && state.studioScrollTop != null) canvas.scrollTop = state.studioScrollTop;
}
function bindStudio(el, catalog, unplaced) {
const status = () => $("#rs-status-text", el);
const setStatus = (msg) => {
if (status()) status().textContent = msg;
};
$("#rs-close", el)?.addEventListener("click", closeStudio);
el.querySelector("[data-rs-close]")?.addEventListener("click", closeStudio);
el.querySelectorAll("[data-rs-rack]").forEach((btn) =>
btn.addEventListener("click", () => {
state.selectedRackId = Number(btn.dataset.rsRack);
renderStudio();
})
);
el.querySelectorAll("[data-rs-side]").forEach((btn) =>
btn.addEventListener("click", () => {
state.rackSide = btn.dataset.rsSide;
renderStudio();
})
);
el.querySelectorAll("[data-rs-zoom]").forEach((btn) =>
btn.addEventListener("click", () => {
const z = btn.dataset.rsZoom;
if (z === "+") state.studioZoom = Math.min(1.8, (state.studioZoom || 1) + 0.1);
else if (z === "-") state.studioZoom = Math.max(0.7, (state.studioZoom || 1) - 0.1);
else state.studioZoom = 1;
renderStudio();
})
);
el.querySelectorAll("[data-libtab]").forEach((btn) =>
btn.addEventListener("click", () => {
state.libTab = btn.dataset.libtab;
renderStudio();
})
);
el.querySelectorAll("[data-fam]").forEach((btn) =>
btn.addEventListener("click", () => {
state.catalogFamily = btn.dataset.fam;
renderStudio();
})
);
const qEl = $("#rs-lib-q", el);
qEl?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
state.libQuery = qEl.value;
renderStudio();
}
});
qEl?.addEventListener("change", () => {
state.libQuery = qEl.value;
renderStudio();
});
const canvas = $("#rs-canvas", el);
canvas?.addEventListener("scroll", () => {
state.studioScrollTop = canvas.scrollTop;
});
el.querySelectorAll("[data-item-del]").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation();
e.preventDefault();
if (!confirm("Remove from rack?")) return;
state.racks = await api("DELETE", `/api/racks/items/${Number(btn.dataset.itemDel)}`);
renderRacks();
renderStudio();
})
);
el.querySelectorAll("[data-item-sel]").forEach((btn) =>
btn.addEventListener("click", (e) => {
if (e.target.closest("[data-item-del]")) return;
if (state._dragMoved) return;
state.selectedItemId = Number(btn.dataset.itemSel);
renderStudio();
})
);
/* ---- Visio-style drag & drop ---- */
const ghost = $("#rs-drag-ghost", el);
let dragPayload = null;
function clearDropHighlight() {
el.querySelectorAll(".rs-u.drop-over, .rs-u.drop-span, .rs-u.drop-bad").forEach((n) => {
n.classList.remove("drop-over", "drop-span", "drop-bad");
});
}
function highlightSpan(uStart, h, ok) {
clearDropHighlight();
const span = uSpan(uStart, h);
for (let u = span.lo; u <= span.hi; u++) {
const zone = el.querySelector(`.rs-u.empty[data-u="${u}"]`);
if (!zone) continue;
zone.classList.add(ok ? "drop-span" : "drop-bad");
if (u === uStart) zone.classList.add("drop-over");
}
el.classList.add("rs-dragging");
return span;
}
function parseDrag(raw) {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveHeight(payload) {
if (!payload) return state.placeH || 1;
if (payload.sku) return catalogHeight(payload.sku, payload.u_height || 1);
return Math.max(1, Number(payload.u_height) || state.placeH || 1);
}
function stencilDragStart(node, e) {
const sku = node.dataset.sku || null;
const deviceId = node.dataset.place ? Number(node.dataset.place) : null;
const h = Number(node.dataset.h || 1);
const catHit = (sku && catalog.find((c) => c.sku === sku)) || null;
const unHit = deviceId ? unplaced.find((d) => d.id === deviceId) : null;
const name = node.dataset.name || catHit?.name || unHit?.name || "Device";
const face = node.dataset.face || catHit?.face || unHit?.face || "server_1u";
const image = node.dataset.img || catHit?.image || unHit?.image || "";
const uHeight = sku
? catalogHeight(sku, h)
: h > 0
? h
: catHit?.u_height > 0
? catHit.u_height
: 1;
dragPayload = {
kind: "stencil",
sku: sku || null,
device_id: deviceId,
u_height: uHeight,
name,
image,
face,
};
state.placeSku = sku || null;
state.placeDeviceId = deviceId;
state.placeH = dragPayload.u_height;
e.dataTransfer.effectAllowed = "copy";
const raw = JSON.stringify(dragPayload);
e.dataTransfer.setData("application/x-rack-item", raw);
e.dataTransfer.setData("text/plain", raw);
if (ghost) {
ghost.hidden = false;
const code = shortModel(name);
ghost.innerHTML = `${productPhoto({ face, image }, name, { compact: true })}
${escape(code)}
${escape(name)}
${uHeight}U · ${escape(face.replace(/_/g, " "))}
`;
try {
e.dataTransfer.setDragImage(ghost, 48, 24);
} catch (_) {}
}
el.classList.add("rs-dragging");
setStatus(`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 U1–U${rk?.units || 42}`);
}
});
zone.addEventListener("dragleave", (e) => {
if (!zone.contains(e.relatedTarget)) {
zone.classList.remove("drop-over", "drop-span", "drop-bad");
}
});
zone.addEventListener("drop", async (e) => {
e.preventDefault();
e.stopPropagation();
const raw =
e.dataTransfer.getData("application/x-rack-item") ||
e.dataTransfer.getData("text/plain");
const payload = parseDrag(raw) || dragPayload;
clearDropHighlight();
el.classList.remove("rs-dragging");
if (!payload) return;
const u = Number(zone.dataset.u);
const h = resolveHeight(payload);
const rk = focusRack();
const exclude = payload.kind === "move" ? payload.item_id : null;
if (!unitsFree(rk, u, h, exclude)) {
setStatus("Drop 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
? `U${String(uLo).padStart(2, "0")}`
: `
${Array.from({ length: hU }, (_, i) => {
const uu = uHi - i;
return `U${String(uu).padStart(2, "0")}`;
}).join("")}
`;
rows.push(`
${uMarks}
${facePlate(p)}
`);
} else {
rows.push(`
U${String(u).padStart(2, "0")}
drop
`);
}
}
const fill = rackFillPct(rk);
return `
${escape(rk.name)}
${side} · ${fill.used}U used · ${fill.free}U free · scroll for all 42U
${
sideItems.map((s) => `${escape(s.side)}: ${escape(s.label || s.catalog?.name || "PDU")}`).join("") ||
"No side PDUs — place 0U PDU on left/right"
}
`;
}
function renderStudioInspector(rk) {
const side = state.rackSide || "front";
const items = [...(rk.items || [])]
.filter((it) => (it.side || "front") === side)
.sort((a, b) => b.u_start - a.u_start);
const fill = rackFillPct(rk);
const sel = items.find((i) => (i.id || i.item_id) === state.selectedItemId);
return `
${escape(rk.name)}
${items.length} items · ${fill.pct}% full
${
sel
? `
Selected
${escape(sel.label || "")}
${escape(sel.catalog?.name || sel.device?.model || "")}
${uSpan(sel.u_start, sel.u_height).label} · ${escape(sel.side || "front")}
${sel.device?.service_tag ? `
ST ${escape(sel.device.service_tag)}
` : ""}
${sel.device?.ip ? `
${escape(sel.device.ip)}
` : ""}
`
: `Select a device in the rack for details
`
}
Rack contents (U42 → U1)
${
items
.map(
(it) => `
`
)
.join("") || '
Empty — pick from Dell products
'
}
`;
}
/* ===== VLANs ===== */
function 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 = `
${
sel
? `
${members.length}Endpoints
${up}Online
${members.length - up}Offline
${Object.keys(roles).length}Roles
${
Object.keys(roles).length
? `${Object.entries(roles)
.sort((a, b) => b[1] - a[1])
.map(([r, n]) => `${escape(r)} · ${n}`)
.join("")}
`
: ""
}
Server → switch / VLAN map
| Server |
Model |
VLANs |
Switch / port |
Note |
${
(matrixFocus.length ? matrixFocus : matrix)
.slice(0, 120)
.map((row) => {
const vlanBits = (row.vlans || [])
.map(
(v) =>
`V${v.vlan_id ?? "?"} ${escape(v.name || "")}`
)
.join(" ");
const note = row.user_note || row.endpoint_note || "";
return `
|
${escape(row.name)}
${escape((row.ips || []).slice(0, 3).join(", "))}${row.service_tag ? " · " + escape(row.service_tag) : ""}
|
${escape(row.model || "—")} |
${vlanBits || "—"} |
${escape(formatSwitchPorts(row.switch_ports))} |
${escape(note || "—")} |
`;
})
.join("") || `| No server attachments yet. |
`
}
Add / note a host
Coupled nodes
${
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 `
${escape(m.name)}
${m.connected ? "online" : "offline"}
Role ${escape(m.role || "—")}
Model ${escape(m.model || "—")}
Service Tag ${escape(m.service_tag || "—")}
IPs in VLAN ${escape((m.ips || []).join(", ") || "—")}
Rack ${rack ? `${escape(rack.site)} / ${escape(rack.rack)} · ${rack.uLabel}` : "not placed"}
Switch ports ${escape(formatSwitchPorts(links))}
${src ? `OME ${escape(src)}` : ""}
${m.endpoint_note ? `System note ${escape(m.endpoint_note)}` : ""}
`;
})
.join("") || '
No live members matched this CIDR yet.
'
}
`
: 'No VLANs
'
}
`;
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 };
})();