(() => { const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const state = { tab: "fabric", site: "ATC1", fabric: null, racks: null, vlans: null, fleet: null, portmap: null, selectedSwitchId: null, selectedPort: null, selectedRackId: null, selectedVlanId: null, placeDeviceId: null, wireDeviceId: null, anim: 0, raf: 0, }; async function api(method, url, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } const r = await fetch(url, opts); if (!r.ok) throw new Error(await r.text()); return r.json(); } function escape(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function openNetwork() { const drawer = $("#network-drawer"); const scrim = $("#scrim"); if (!drawer) return; ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => { const el = $(id); if (el) { el.classList.remove("open"); el.setAttribute("aria-hidden", "true"); } }); drawer.classList.add("open"); drawer.setAttribute("aria-hidden", "false"); if (scrim) { scrim.classList.add("open"); scrim.dataset.mode = "network-drawer"; } showTab(state.tab); refresh(); } function closeNetwork() { closeStudio(); const drawer = $("#network-drawer"); const scrim = $("#scrim"); drawer?.classList.remove("open"); drawer?.setAttribute("aria-hidden", "true"); if (scrim?.dataset.mode === "network-drawer") { scrim.classList.remove("open"); delete scrim.dataset.mode; } stopPulse(); } function showTab(tab) { state.tab = tab; $$("#network-tabs .chip").forEach((c) => c.classList.toggle("active", c.dataset.ntab === tab)); $("#network-fabric")?.classList.toggle("hidden", tab !== "fabric"); $("#network-racks")?.classList.toggle("hidden", tab !== "racks"); $("#network-vlans")?.classList.toggle("hidden", tab !== "vlans"); if (tab === "fabric") startPulse(); else stopPulse(); } function stopPulse() { if (state.raf) cancelAnimationFrame(state.raf); state.raf = 0; } function startPulse() { stopPulse(); const tick = (ts) => { state.anim = ts / 1000; drawCables(); state.raf = requestAnimationFrame(tick); }; state.raf = requestAnimationFrame(tick); } async function refresh() { const mountHint = $("#network-status"); if (mountHint) mountHint.textContent = "Loading…"; try { const [fleet, fabric, racks, vlans] = await Promise.all([ api("GET", "/api/fleet"), api("GET", "/api/network/fabric"), api("GET", "/api/racks"), api("GET", "/api/network/vlans"), ]); state.fleet = fleet; state.fabric = fabric; state.racks = racks; state.vlans = vlans; const switches = (fabric.switches || []).length ? fabric.switches : (fleet.devices || []).filter((d) => d.is_switch); if (!state.selectedSwitchId && switches[0]) { state.selectedSwitchId = switches[0].id; } if (state.selectedSwitchId) { state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`); } const wired = state.portmap?.wired_count || 0; const ports = state.portmap?.switch?.port_count || 0; if (mountHint) { mountHint.textContent = `${switches.length} switch · ${wired}/${ports} ports wired · manual port map`; } renderFabric(); renderRacks(); renderVlans(); if (state.tab === "fabric") startPulse(); } catch (e) { if (mountHint) mountHint.textContent = "Error: " + e.message; } } function devices() { return state.fleet?.devices || []; } function switchList() { const fromFabric = state.fabric?.switches || []; if (fromFabric.length) return fromFabric; return devices() .filter((d) => d.is_switch) .map((s) => ({ id: s.id, name: s.name, service_tag: s.service_tag, ip: s.ip || s.idrac_ip, subnet: s.subnet, model: s.model, connected_device_ids: [], })); } function renderFabric() { const root = $("#network-fabric"); if (!root) return; const switches = switchList(); const pm = state.portmap; const sw = pm?.switch; const ports = pm?.ports || []; const selected = state.selectedPort; const selPort = ports.find((p) => p.port === selected); const servers = devices().filter( (d) => d.is_server || d.is_storage || d.is_chassis || d.is_idrac ); root.innerHTML = `
${ sw ? `
DELL ${escape((sw.model || "").replace("Dell EMC Networking ", "").replace(" switch/router", ""))} ${escape(sw.service_tag || "")}
${escape(sw.name)} ${escape(sw.ip || "—")} · ${escape(sw.subnet || "")} ${pm.wired_count}/${sw.port_count} ports wired
${ports .map((p) => { const cls = [ "sw-port", p.wired ? "wired" : "empty", selected === p.port ? "selected" : "", p.link?.device?.connected ? "live" : "", ] .filter(Boolean) .join(" "); const tip = p.wired ? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}` : `${p.label} — click to wire`; return ``; }) .join("")}
${ports .filter((p) => p.wired && p.link?.device) .map((p) => { const d = p.link.device; return `
P${p.port}
${escape(d.name)} NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")}
`; }) .join("") || '

No ports wired yet — click a port on the switch.

'}
` : `

Select a switch.

` }
`; root.querySelectorAll("[data-sw]").forEach((btn) => { btn.addEventListener("click", async () => { state.selectedSwitchId = Number(btn.dataset.sw); state.selectedPort = null; state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`); renderFabric(); startPulse(); }); }); root.querySelectorAll("[data-port]").forEach((btn) => { btn.addEventListener("click", () => { state.selectedPort = Number(btn.dataset.port); renderFabric(); startPulse(); }); }); $("#sw-wire-save")?.addEventListener("click", saveWire); $("#sw-wire-clear")?.addEventListener("click", clearWire); $("#sw-wire-device")?.addEventListener("change", onWireDeviceChange); requestAnimationFrame(() => drawCables()); } function renderWirePane(selPort, servers) { if (!selPort) { return `

Port wiring

Click a port on the switch faceplate to map it to a server NIC / port.

`; } const link = selPort.link; const curDev = link?.device_id || state.wireDeviceId || ""; const curPort = link?.device_port || ""; const opts = servers .slice() .sort((a, b) => (a.name || "").localeCompare(b.name || "")) .map( (d) => `` ) .join(""); return `

Wire ${escape(selPort.label)}

Switch port ${selPort.port} → server + NIC/port

${selPort.wired ? `` : ""}
${ link?.device ? `
Current ${escape(link.device.name)} ${escape(link.device_port || "—")} · ${link.device.connected ? "connected" : "offline"}
` : "" } `; } async function onWireDeviceChange() { const id = Number($("#sw-wire-device")?.value || 0); state.wireDeviceId = id || null; const list = $("#sw-nic-list"); if (!list || !id) return; list.innerHTML = ""; try { const data = await api("GET", `/api/devices/${id}/nics`); (data.nics || []).forEach((n) => { const opt = document.createElement("option"); opt.value = n.fqdd || n.name; opt.label = `${n.name}${n.mac ? " · " + n.mac : ""}`; list.appendChild(opt); }); if ((data.nics || [])[0] && !$("#sw-wire-nic")?.value) { $("#sw-wire-nic").value = data.nics[0].fqdd || data.nics[0].name || ""; } } catch (_) { /* ignore */ } } async function saveWire() { const port = state.selectedPort; const deviceId = Number($("#sw-wire-device")?.value || 0); const nic = ($("#sw-wire-nic")?.value || "").trim(); const note = ($("#sw-wire-note")?.value || "").trim(); if (!state.selectedSwitchId || !port) return; if (!deviceId) { alert("Select a server / endpoint"); return; } try { state.portmap = await api("PUT", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`, { switch_port: port, device_id: deviceId, device_port: nic, note, }); renderFabric(); startPulse(); } catch (e) { alert("Save failed: " + e.message); } } async function clearWire() { const port = state.selectedPort; if (!state.selectedSwitchId || !port) return; if (!confirm(`Unwire switch port ${port}?`)) return; try { state.portmap = await api("DELETE", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`); renderFabric(); startPulse(); } catch (e) { alert(e.message); } } function drawCables() { const canvas = $("#sw-cable-canvas"); const grid = $("#sw-port-grid"); const rail = $("#sw-peer-rail"); const stage = canvas?.parentElement; if (!canvas || !grid || !stage || state.tab !== "fabric") return; const dpr = window.devicePixelRatio || 1; const w = stage.clientWidth; const h = stage.clientHeight; if (w < 10 || h < 10) return; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); canvas.style.width = w + "px"; canvas.style.height = h + "px"; const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const stageBox = stage.getBoundingClientRect(); const t = state.anim; const ports = state.portmap?.ports || []; ports.forEach((p) => { if (!p.wired) return; const portBtn = grid.querySelector(`[data-port="${p.port}"]`); const peer = rail?.querySelector(`[data-peer-port="${p.port}"]`); if (!portBtn || !peer) return; const a = portBtn.getBoundingClientRect(); const b = peer.getBoundingClientRect(); const x1 = a.left + a.width / 2 - stageBox.left; const y1 = a.bottom - stageBox.top; const x2 = b.left - stageBox.left; const y2 = b.top + b.height / 2 - stageBox.top; const live = !!p.link?.device?.connected; const midY = y1 + Math.max(24, (y2 - y1) * 0.35); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2); ctx.strokeStyle = live ? "rgba(61,255,224,0.55)" : "rgba(0,168,232,0.35)"; ctx.lineWidth = live ? 2.4 : 1.6; ctx.setLineDash([8, 7]); ctx.lineDashOffset = -t * (live ? 55 : 22); ctx.stroke(); if (live) { const pulse = (Math.sin(t * 5 + p.port) + 1) / 2; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2); ctx.strokeStyle = `rgba(255,154,60,${0.2 + pulse * 0.55})`; ctx.lineWidth = 3.2; ctx.setLineDash([]); ctx.stroke(); } // moving packet const u = (t * 0.35 + p.port * 0.07) % 1; const pt = bezierPoint(x1, y1, x1, midY, x2 - 40, y2, x2, y2, u); ctx.beginPath(); ctx.arc(pt.x, pt.y, live ? 3.2 : 2.2, 0, Math.PI * 2); ctx.fillStyle = live ? "#ff9a3c" : "#00a8e8"; ctx.fill(); }); } function bezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) { const u = 1 - t; return { x: u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3, y: u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3, }; } /* ===== Dell Rack Design Studio ===== */ const U_OVERVIEW = 7; const U_STUDIO = 56; // full-bleed faceplate height per U function roleOf(it) { return (it.category || it.catalog?.category || it.device?.role || it.face || "custom").toLowerCase(); } function roleClass(role) { const r = (role || "").toLowerCase(); if (r.includes("switch")) return "switch"; if (r.includes("storage")) return "storage"; if (r.includes("chassis")) return "chassis"; if (r.includes("pdu")) return "pdu"; if (r.includes("blank")) return "blank"; if (r.includes("server") || r.includes("blade") || r.includes("vxrail")) return "server"; return "custom"; } /** u_start = bottom U; device fills u_start .. u_start+h-1 (higher U = toward top of rack). */ function uSpan(uStart, h) { const lo = Math.max(1, Number(uStart) || 1); const height = Math.max(1, Number(h) || 1); const hi = lo + height - 1; return { lo, hi, height, label: height === 1 ? `U${lo}` : `U${lo}–U${hi}` }; } function catalogHeight(sku, fallback = 1) { const cat = (state.racks?.catalog || []).find((c) => c.sku === sku); const h = Number(cat?.u_height); return h > 0 ? h : Math.max(1, Number(fallback) || 1); } function unitsFree(rk, uStart, h, excludeItemId) { const side = state.rackSide || "front"; const units = rk?.units || 42; const span = uSpan(uStart, h); if (span.hi > units) return false; const items = (rk?.items || []).filter((it) => (it.side || "front") === side); for (const it of items) { if (excludeItemId != null && (it.id || it.item_id) === excludeItemId) continue; const a = it.u_start; const b = it.u_start + Math.max(1, it.u_height || 1) - 1; if (!(span.hi < a || span.lo > b)) return false; } return true; } function rackFillPct(rk) { const items = (rk.items || []).filter((it) => (it.side || "front") === "front"); const used = items.reduce((a, p) => a + Math.max(0, p.u_height || 0), 0); const units = rk.units || 42; return { used, free: Math.max(0, units - used), pct: Math.round((used / units) * 100), units }; } function allRacksList() { const sites = state.racks?.sites || []; const show = state.site === "Both" ? sites : sites.filter((s) => s.id === state.site); return show.flatMap((s) => (s.racks || []).map((r) => ({ ...r, siteName: s.name }))); } function focusRack() { const all = allRacksList(); return all.find((r) => r.id === state.selectedRackId) || all[0] || null; } let _faceSeq = 0; function faceSvg(face, label, hU, opts = {}) { const px = opts.px || U_STUDIO; const h = Math.max(px, Math.max(1, hU || 1) * px); const w = opts.w || 420; const compact = !!opts.compact; const title = escape((label || "").slice(0, 36)); const gid = "fg" + (++_faceSeq); const cat = (face || "").toLowerCase(); if (cat.startsWith("switch")) { const n = compact ? 16 : 24; let ports = ""; for (let i = 0; i < n; i++) { const x = 40 + i * ((w - 70) / n); ports += ``; } return ` 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)}

Klik een rack om de Design Studio te openen (inzoomen, alle 42U, Dell catalogus).

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(`Sleep ${name} (${uHeight}U) — drop op de onderkant-U (belegt omhoog)`); node.classList.add("dragging"); } function itemDragStart(node, e) { if (e.target.closest("[data-item-del]")) { e.preventDefault(); return; } state._dragMoved = false; const h = Math.max(1, Number(node.dataset.h || 1)); dragPayload = { kind: "move", item_id: Number(node.dataset.itemId), u_height: h, name: node.querySelector(".rs-u-plate strong")?.textContent || node.querySelector("strong")?.textContent || "Device", }; e.dataTransfer.effectAllowed = "move"; const raw = JSON.stringify(dragPayload); e.dataTransfer.setData("application/x-rack-item", raw); e.dataTransfer.setData("text/plain", raw); el.classList.add("rs-dragging"); setStatus(`Verplaatsen (${h}U): drop op nieuwe onderkant-U`); node.classList.add("dragging"); } el.querySelectorAll(".rs-stencil").forEach((node) => { node.addEventListener("dragstart", (e) => stencilDragStart(node, e)); node.addEventListener("dragend", () => { node.classList.remove("dragging"); clearDropHighlight(); el.classList.remove("rs-dragging"); if (ghost) ghost.hidden = true; dragPayload = null; setStatus("Sleep stencils uit de bibliotheek naar het rack · drop op onderkant-U"); }); // click still selects for keyboard users node.addEventListener("click", () => { if (node.dataset.sku) { state.placeSku = node.dataset.sku; state.placeDeviceId = null; state.placeH = Number(node.dataset.h || 1); } else if (node.dataset.place) { state.placeDeviceId = Number(node.dataset.place); state.placeSku = node.dataset.sku || null; state.placeH = Number(node.dataset.h || 1); } setStatus(`Geselecteerd: ${node.dataset.name} — sleep of klik een U`); el.querySelectorAll(".rs-stencil").forEach((n) => n.classList.toggle("active", n === node)); }); }); el.querySelectorAll(".rs-u.filled").forEach((node) => { node.addEventListener("dragstart", (e) => itemDragStart(node, e)); node.addEventListener("dragend", () => { node.classList.remove("dragging"); clearDropHighlight(); el.classList.remove("rs-dragging"); dragPayload = null; setTimeout(() => { state._dragMoved = false; }, 0); }); }); el.querySelectorAll("[data-drop-u]").forEach((zone) => { zone.addEventListener("dragover", (e) => { e.preventDefault(); const rk = focusRack(); const u = Number(zone.dataset.u); const h = resolveHeight(dragPayload); const exclude = dragPayload?.kind === "move" ? dragPayload.item_id : null; const ok = unitsFree(rk, u, h, exclude); e.dataTransfer.dropEffect = ok ? (dragPayload?.kind === "move" ? "move" : "copy") : "none"; const span = highlightSpan(u, h, ok); if (ok) { setStatus(`Drop → ${span.label} (${span.height}U)`); } else { setStatus(`Past niet op ${span.label} — overlap of buiten U1–U${rk?.units || 42}`); } }); zone.addEventListener("dragleave", (e) => { if (!zone.contains(e.relatedTarget)) { zone.classList.remove("drop-over", "drop-span", "drop-bad"); } }); zone.addEventListener("drop", async (e) => { e.preventDefault(); e.stopPropagation(); const raw = e.dataTransfer.getData("application/x-rack-item") || e.dataTransfer.getData("text/plain"); const payload = parseDrag(raw) || dragPayload; clearDropHighlight(); el.classList.remove("rs-dragging"); if (!payload) return; const u = Number(zone.dataset.u); const h = resolveHeight(payload); const rk = focusRack(); const exclude = payload.kind === "move" ? payload.item_id : null; if (!unitsFree(rk, u, h, exclude)) { setStatus("Drop geblokkeerd — U-bereik niet vrij"); return; } state._dragMoved = true; try { if (payload.kind === "move" && payload.item_id) { state.racks = await api("PATCH", `/api/racks/items/${payload.item_id}`, { u_start: u, u_height: h, side: state.rackSide || "front", }); } else { state.placeSku = payload.sku || null; state.placeDeviceId = payload.device_id || null; state.placeH = h; state.placeU = u; state.racks = await api("POST", "/api/racks/items", { rack_id: state.selectedRackId, u_start: u, u_height: h, side: state.rackSide || "front", device_id: payload.device_id || null, catalog_sku: payload.sku || null, }); state.placeSku = null; state.placeDeviceId = null; } renderRacks(); renderStudio(); setStatus(`Geplaatst op ${uSpan(u, h).label}`); } catch (err) { alert("Drop failed: " + err.message); setStatus("Drop mislukt — probeer een vrije U"); } }); }); // click-to-place still works when stencil preselected el.querySelectorAll(".rs-u.empty").forEach((node) => { node.addEventListener("click", () => { if (!state.placeDeviceId && !state.placeSku) return; const u = Number(node.dataset.u); const h = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, state.placeH || 1); const rk = focusRack(); if (!unitsFree(rk, u, h, null)) { setStatus(`Past niet op ${uSpan(u, h).label}`); return; } state.placeU = u; state.placeH = h; placeCurrent(true); }); node.addEventListener("mouseenter", () => { if (!state.placeDeviceId && !state.placeSku) return; if (dragPayload) return; const u = Number(node.dataset.u); const h = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, state.placeH || 1); const rk = focusRack(); const ok = unitsFree(rk, u, h, null); highlightSpan(u, h, ok); setStatus(ok ? `Klik → ${uSpan(u, h).label}` : `Past niet op ${uSpan(u, h).label}`); }); node.addEventListener("mouseleave", () => { if (!dragPayload) clearDropHighlight(); }); }); if (el._onKey) document.removeEventListener("keydown", el._onKey); el._onKey = (ev) => { if (ev.key === "Escape") closeStudio(); }; document.addEventListener("keydown", el._onKey); } async function placeCurrent(fromStudio) { if (!state.selectedRackId) return; const uStart = Number(state.placeU || 1); const height = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, Number(state.placeH || 1)); const side = state.rackSide || "front"; const rk = focusRack(); if (!unitsFree(rk, uStart, height, null)) { alert(`Past niet op ${uSpan(uStart, height).label}`); return; } try { state.racks = await api("POST", "/api/racks/items", { rack_id: state.selectedRackId, u_start: uStart, u_height: height, side, device_id: state.placeDeviceId || null, catalog_sku: state.placeSku || null, }); state.placeDeviceId = null; state.placeSku = null; renderRacks(); if (state.studioOpen) renderStudio(); } catch (err) { alert("Place failed: " + err.message); } } function renderStudioRack(rk) { const units = rk.units || 42; const side = state.rackSide || "front"; const items = (rk.items || []).filter((it) => (it.side || "front") === side); const sideItems = (rk.items || []).filter((it) => it.side === "left" || it.side === "right"); const occ = new Array(units + 1).fill(null); items.forEach((p) => { for (let u = p.u_start; u < p.u_start + p.u_height && u <= units; u++) occ[u] = p; }); const rows = []; for (let u = units; u >= 1; u--) { const p = occ[u]; const isStart = p && p.u_start === u; if (p && !isStart) continue; if (p && isStart) { const hU = Math.max(1, Number(p.u_height) || 1); const h = hU * U_STUDIO; const rc = roleClass(roleOf(p)); const selected = state.selectedItemId === (p.id || p.item_id); const uLo = p.u_start; const uHi = p.u_start + hU - 1; /* Rail labels: high U at top (rack reads top→bottom) */ const uMarks = hU === 1 ? `U${String(uLo).padStart(2, "0")}` : ` ${Array.from({ length: hU }, (_, i) => { const uu = uHi - i; return `U${String(uu).padStart(2, "0")}`; }).join("")} `; rows.push(`
${uMarks} ${facePlate(p)}
`); } else { rows.push(`
U${String(u).padStart(2, "0")} drop
`); } } const fill = rackFillPct(rk); return `
${escape(rk.name)} ${side} · ${fill.used}U used · ${fill.free}U free · scroll for all 42U
${ sideItems.map((s) => `${escape(s.side)}: ${escape(s.label || s.catalog?.name || "PDU")}`).join("") || "No side PDUs — place 0U PDU on left/right" }
${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 renderVlans() { const root = $("#network-vlans"); if (!root || !state.vlans) return; const vlans = state.vlans.vlans || []; if (state.selectedVlanId == null && vlans[0]) state.selectedVlanId = vlans[0].id; const sel = vlans.find((v) => v.id === state.selectedVlanId) || vlans[0]; root.innerHTML = `
${ sel ? `

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

${escape(sel.cidr)} · ${escape(sel.site_id || "—")} · ${escape(sel.purpose || "")}

${sel.member_count} members
${(sel.members || []) .map( (m) => `
${escape(m.name)} ST=${escape(m.service_tag || "—")} · ${escape(m.role || "")} · ${(m.ips || []).join(", ")}
` ) .join("") || '

No live members matched this CIDR.

'}
` : '

No VLANs

' }
`; root.querySelectorAll("[data-vlan]").forEach((btn) => { btn.addEventListener("click", () => { state.selectedVlanId = Number(btn.dataset.vlan); renderVlans(); }); }); } function bind() { $("#btn-network")?.addEventListener("click", openNetwork); $("#btn-network-close")?.addEventListener("click", closeNetwork); $("#network-tabs")?.addEventListener("click", (e) => { const chip = e.target.closest("[data-ntab]"); if (!chip) return; showTab(chip.dataset.ntab); }); $("#scrim")?.addEventListener("click", () => { if ($("#scrim")?.dataset.mode === "network-drawer") closeNetwork(); }); window.addEventListener("resize", () => { if (state.tab === "fabric") drawCables(); }); } bind(); window.cockpitNetwork = { open: openNetwork, close: closeNetwork, refresh }; })();