(() => { 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 `
P${p.port}
${escape(d.name)} ${escape(nicLabel)} · ST=${escape(d.service_tag || "—")} ${d.connected ? "connected" : "offline"}
`; }) .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 ` DELL ${ports} `; } if (cat.startsWith("storage")) { const cols = compact ? 10 : 12; let bays = ""; for (let i = 0; i < cols; i++) { bays += ``; } return `${bays}`; } if (cat.includes("chassis") || cat === "chassis_mx") { const n = compact ? 5 : 8; return ` ${Array.from({ length: n }, (_, i) => ``).join("")} `; } if (cat.startsWith("pdu")) { return ` ${Array.from({ length: compact ? 8 : 12 }, (_, i) => ``).join("")} `; } 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 ` PowerEdge ${disks} ${compact ? "" : `${title}`} `; } 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 `${escape(label || `; } 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("")}

Rack floor · ${escape(state.site === "Both" ? "ATC1 + ATC2" : state.site)}

Click a rack to open Design Studio (zoom, all 42U, Dell catalog).

ServerSwitch StorageChassisPDU
`; 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 = `

Geen rack

`; 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 = `
`; 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" }
${rows.join("")}
`; } 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 ? `

VLAN ${sel.vlan_id ?? "—"} · ${escape(sel.name)}

${escape(sel.cidr)}

${escape(sel.purpose || "No purpose set")} · site ${escape(sel.site_id || "—")}

${members.length} nodes ${up} connected
${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

${ (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 ``; }) .join("") || `` }
Server Model VLANs Switch / port Note
${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 || "—")}
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 }; })();