b4714c70d1
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match. Co-authored-by: Cursor <cursoragent@cursor.com>
1810 lines
74 KiB
JavaScript
1810 lines
74 KiB
JavaScript
(() => {
|
||
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, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function openNetwork() {
|
||
const drawer = $("#network-drawer");
|
||
const scrim = $("#scrim");
|
||
if (!drawer) return;
|
||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer", "#team-drawer"].forEach((id) => {
|
||
const el = $(id);
|
||
if (el) {
|
||
el.classList.remove("open");
|
||
el.setAttribute("aria-hidden", "true");
|
||
}
|
||
});
|
||
drawer.classList.add("open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
if (scrim) {
|
||
scrim.classList.add("open");
|
||
scrim.dataset.mode = "network-drawer";
|
||
}
|
||
showTab(state.tab);
|
||
refresh();
|
||
}
|
||
|
||
function closeNetwork() {
|
||
closeStudio();
|
||
const drawer = $("#network-drawer");
|
||
const scrim = $("#scrim");
|
||
drawer?.classList.remove("open");
|
||
drawer?.setAttribute("aria-hidden", "true");
|
||
if (scrim?.dataset.mode === "network-drawer") {
|
||
scrim.classList.remove("open");
|
||
delete scrim.dataset.mode;
|
||
}
|
||
stopPulse();
|
||
}
|
||
|
||
function showTab(tab) {
|
||
state.tab = tab;
|
||
$$("#network-tabs .chip").forEach((c) => c.classList.toggle("active", c.dataset.ntab === tab));
|
||
$("#network-fabric")?.classList.toggle("hidden", tab !== "fabric");
|
||
$("#network-racks")?.classList.toggle("hidden", tab !== "racks");
|
||
$("#network-vlans")?.classList.toggle("hidden", tab !== "vlans");
|
||
if (tab === "fabric") startPulse();
|
||
else stopPulse();
|
||
}
|
||
|
||
function stopPulse() {
|
||
if (state.raf) cancelAnimationFrame(state.raf);
|
||
state.raf = 0;
|
||
}
|
||
|
||
function startPulse() {
|
||
stopPulse();
|
||
const tick = (ts) => {
|
||
state.anim = ts / 1000;
|
||
drawCables();
|
||
state.raf = requestAnimationFrame(tick);
|
||
};
|
||
state.raf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
async function refresh() {
|
||
const mountHint = $("#network-status");
|
||
if (mountHint) mountHint.textContent = "Loading…";
|
||
try {
|
||
const [fleet, fabric, racks, vlans] = await Promise.all([
|
||
api("GET", "/api/fleet"),
|
||
api("GET", "/api/network/fabric"),
|
||
api("GET", "/api/racks"),
|
||
api("GET", "/api/network/vlans"),
|
||
]);
|
||
state.fleet = fleet;
|
||
state.fabric = fabric;
|
||
state.racks = racks;
|
||
state.vlans = vlans;
|
||
const switches = (fabric.switches || []).length
|
||
? fabric.switches
|
||
: (fleet.devices || []).filter((d) => d.is_switch);
|
||
if (!state.selectedSwitchId && switches[0]) {
|
||
state.selectedSwitchId = switches[0].id;
|
||
}
|
||
if (state.selectedSwitchId) {
|
||
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
|
||
}
|
||
const wired = state.portmap?.wired_count || 0;
|
||
const ports = state.portmap?.switch?.port_count || 0;
|
||
if (mountHint) {
|
||
mountHint.textContent = `${switches.length} switch · ${wired}/${ports} ports wired · manual port map`;
|
||
}
|
||
renderFabric();
|
||
renderRacks();
|
||
renderVlans();
|
||
if (state.tab === "fabric") startPulse();
|
||
} catch (e) {
|
||
if (mountHint) mountHint.textContent = "Error: " + e.message;
|
||
}
|
||
}
|
||
|
||
function devices() {
|
||
return state.fleet?.devices || [];
|
||
}
|
||
|
||
function switchList() {
|
||
const fromFabric = state.fabric?.switches || [];
|
||
if (fromFabric.length) return fromFabric;
|
||
return devices()
|
||
.filter((d) => d.is_switch)
|
||
.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
service_tag: s.service_tag,
|
||
ip: s.ip || s.idrac_ip,
|
||
subnet: s.subnet,
|
||
model: s.model,
|
||
connected_device_ids: [],
|
||
}));
|
||
}
|
||
|
||
function renderFabric() {
|
||
const root = $("#network-fabric");
|
||
if (!root) return;
|
||
const switches = switchList();
|
||
const pm = state.portmap;
|
||
const sw = pm?.switch;
|
||
const ports = pm?.ports || [];
|
||
const selected = state.selectedPort;
|
||
const selPort = ports.find((p) => p.port === selected);
|
||
|
||
const servers = devices().filter(
|
||
(d) => d.is_server || d.is_storage || d.is_chassis || d.is_idrac
|
||
);
|
||
|
||
root.innerHTML = `
|
||
<div class="sw-fabric">
|
||
<aside class="sw-list-pane">
|
||
<h4>Switches</h4>
|
||
<div class="sw-cards">
|
||
${switches
|
||
.map((s) => {
|
||
const active = s.id === state.selectedSwitchId ? " active" : "";
|
||
return `<button type="button" class="sw-card${active}" data-sw="${s.id}">
|
||
<div class="sw-card-ico" aria-hidden="true"></div>
|
||
<div class="sw-card-body">
|
||
<strong>${escape(s.name)}</strong>
|
||
<span>${escape(s.model || "Switch")}</span>
|
||
<span class="mono">ST=${escape(s.service_tag || "—")} · ${escape(s.ip || "—")}</span>
|
||
</div>
|
||
</button>`;
|
||
})
|
||
.join("") || '<p class="hint">No switches in OME.</p>'}
|
||
</div>
|
||
<p class="hint">${escape(pm?.note || "Select a switch to map ports.")}</p>
|
||
</aside>
|
||
|
||
<section class="sw-face-pane">
|
||
${
|
||
sw
|
||
? `<div class="sw-faceplate" id="sw-faceplate">
|
||
<div class="sw-face-head">
|
||
<div class="sw-brand-bar">
|
||
<span class="sw-dell">DELL</span>
|
||
<span class="sw-model">${escape((sw.model || "").replace("Dell EMC Networking ", "").replace(" switch/router", ""))}</span>
|
||
<span class="sw-st mono">${escape(sw.service_tag || "")}</span>
|
||
</div>
|
||
<div class="sw-face-meta">
|
||
<span>${escape(sw.name)}</span>
|
||
<span class="mono">${escape(sw.ip || "—")} · ${escape(sw.subnet || "")}</span>
|
||
<span class="sw-wired-pill">${pm.wired_count}/${sw.port_count} ports wired</span>
|
||
</div>
|
||
</div>
|
||
<div class="sw-cable-stage" id="sw-cable-stage">
|
||
<canvas id="sw-cable-canvas" aria-hidden="true"></canvas>
|
||
<div class="sw-port-grid" id="sw-port-grid">
|
||
${ports
|
||
.map((p) => {
|
||
const cls = [
|
||
"sw-port",
|
||
p.wired ? "wired" : "empty",
|
||
selected === p.port ? "selected" : "",
|
||
p.link?.device?.connected ? "live" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
const tip = p.wired
|
||
? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}`
|
||
: `${p.label} — click to wire`;
|
||
return `<button type="button" class="${cls}" data-port="${p.port}" title="${escape(tip)}">
|
||
<span class="sw-port-num">${p.port}</span>
|
||
${p.wired ? '<span class="sw-port-dot"></span>' : ""}
|
||
</button>`;
|
||
})
|
||
.join("")}
|
||
</div>
|
||
<div class="sw-peer-rail" id="sw-peer-rail">
|
||
<p class="sw-peer-hint">Drag servers to rearrange · pick cable color per link</p>
|
||
${orderedWiredPorts(ports)
|
||
.map((p) => {
|
||
const d = p.link.device;
|
||
const col = getCableColor(p.port);
|
||
const nicLabel = p.link.device_port || "—";
|
||
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" draggable="true" data-peer-port="${p.port}" style="--cable:${escape(col)}">
|
||
<span class="sw-peer-grip" title="Drag to reorder" aria-hidden="true">⋮⋮</span>
|
||
<span class="sw-peer-port">P${p.port}</span>
|
||
<div class="sw-peer-body">
|
||
<strong>${escape(d.name)}</strong>
|
||
<span class="mono">${escape(nicLabel)} · ST=${escape(d.service_tag || "—")}</span>
|
||
<span class="sw-peer-status">${d.connected ? "connected" : "offline"}</span>
|
||
</div>
|
||
<label class="sw-cable-color" title="Cable / pulse color">
|
||
<input type="color" value="${escape(col)}" data-cable-color="${p.port}" />
|
||
</label>
|
||
</div>`;
|
||
})
|
||
.join("") || '<p class="hint">No ports wired yet — click a port on the switch.</p>'}
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
: `<p class="hint" style="padding:1rem">Select a switch.</p>`
|
||
}
|
||
</section>
|
||
|
||
<aside class="sw-wire-pane" id="sw-wire-pane">
|
||
${renderWirePane(selPort, servers)}
|
||
</aside>
|
||
</div>
|
||
`;
|
||
|
||
root.querySelectorAll("[data-sw]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
state.selectedSwitchId = Number(btn.dataset.sw);
|
||
state.selectedPort = null;
|
||
state.portmap = await api("GET", `/api/network/switches/${state.selectedSwitchId}/ports`);
|
||
renderFabric();
|
||
startPulse();
|
||
});
|
||
});
|
||
root.querySelectorAll("[data-port]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
state.selectedPort = Number(btn.dataset.port);
|
||
renderFabric();
|
||
startPulse();
|
||
});
|
||
});
|
||
$("#sw-wire-save")?.addEventListener("click", saveWire);
|
||
$("#sw-wire-clear")?.addEventListener("click", clearWire);
|
||
$("#sw-wire-device")?.addEventListener("change", onWireDeviceChange);
|
||
if ($("#sw-wire-device")?.value) onWireDeviceChange();
|
||
bindPeerRail($("#sw-peer-rail"));
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => drawCables());
|
||
});
|
||
}
|
||
|
||
function bindPeerRail(rail) {
|
||
if (!rail) return;
|
||
let dragPort = null;
|
||
rail.querySelectorAll("[data-cable-color]").forEach((inp) => {
|
||
inp.addEventListener("input", (e) => {
|
||
e.stopPropagation();
|
||
const port = Number(inp.dataset.cableColor);
|
||
setCableColor(port, inp.value);
|
||
const chip = inp.closest(".sw-peer-chip");
|
||
if (chip) chip.style.setProperty("--cable", inp.value);
|
||
drawCables();
|
||
});
|
||
inp.addEventListener("click", (e) => e.stopPropagation());
|
||
inp.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
});
|
||
rail.querySelectorAll(".sw-peer-chip[draggable]").forEach((chip) => {
|
||
chip.addEventListener("dragstart", (e) => {
|
||
dragPort = Number(chip.dataset.peerPort);
|
||
chip.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", String(dragPort));
|
||
});
|
||
chip.addEventListener("dragend", () => {
|
||
chip.classList.remove("dragging");
|
||
rail.querySelectorAll(".sw-peer-chip").forEach((n) => n.classList.remove("drag-over"));
|
||
dragPort = null;
|
||
drawCables();
|
||
});
|
||
chip.addEventListener("dragover", (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
chip.classList.add("drag-over");
|
||
});
|
||
chip.addEventListener("dragleave", () => chip.classList.remove("drag-over"));
|
||
chip.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
chip.classList.remove("drag-over");
|
||
const from = Number(e.dataTransfer.getData("text/plain") || dragPort);
|
||
const to = Number(chip.dataset.peerPort);
|
||
if (!from || !to || from === to) return;
|
||
const ports = orderedWiredPorts(state.portmap?.ports || []).map((p) => p.port);
|
||
const fi = ports.indexOf(from);
|
||
const ti = ports.indexOf(to);
|
||
if (fi < 0 || ti < 0) return;
|
||
ports.splice(fi, 1);
|
||
ports.splice(ti, 0, from);
|
||
state.peerOrder[state.selectedSwitchId] = ports;
|
||
saveFabricPrefs();
|
||
renderFabric();
|
||
startPulse();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderWirePane(selPort, servers) {
|
||
if (!selPort) {
|
||
return `<h4>Port wiring</h4><p class="hint">Click a port on the switch faceplate to map it to a server NIC / port.</p>`;
|
||
}
|
||
const link = selPort.link;
|
||
const curDev = link?.device_id || state.wireDeviceId || "";
|
||
const curPort = link?.device_port || "";
|
||
const opts = servers
|
||
.slice()
|
||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
|
||
.map(
|
||
(d) =>
|
||
`<option value="${d.id}" ${String(d.id) === String(curDev) ? "selected" : ""}>${escape(d.name)} · ST=${escape(d.service_tag || "—")}</option>`
|
||
)
|
||
.join("");
|
||
return `
|
||
<h4>Wire ${escape(selPort.label)}</h4>
|
||
<p class="hint">Switch port <strong>${selPort.port}</strong> → server + NIC/port</p>
|
||
<label class="sw-field">Server / endpoint
|
||
<select id="sw-wire-device">
|
||
<option value="">— select —</option>
|
||
${opts}
|
||
</select>
|
||
</label>
|
||
<label class="sw-field">Server port / NIC
|
||
<select id="sw-wire-nic-pick">
|
||
<option value="">— load after selecting server —</option>
|
||
</select>
|
||
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="PortId / FQDD (e.g. NIC.Slot.1-1)" value="${escape(curPort)}" />
|
||
<datalist id="sw-nic-list"></datalist>
|
||
</label>
|
||
<label class="sw-field">Note
|
||
<input type="text" id="sw-wire-note" placeholder="optional" value="${escape(link?.note || "")}" />
|
||
</label>
|
||
<div class="sw-wire-actions">
|
||
<button type="button" class="btn primary" id="sw-wire-save">Save link</button>
|
||
${selPort.wired ? `<button type="button" class="btn danger" id="sw-wire-clear">Unwire</button>` : ""}
|
||
</div>
|
||
${
|
||
link?.device
|
||
? `<div class="sw-wire-current">
|
||
<strong>Current</strong>
|
||
<span>${escape(link.device.name)}</span>
|
||
<span class="mono">${escape(link.device_port || "—")} · ${link.device.connected ? "connected" : "offline"}</span>
|
||
</div>`
|
||
: ""
|
||
}
|
||
`;
|
||
}
|
||
|
||
async function onWireDeviceChange() {
|
||
const id = Number($("#sw-wire-device")?.value || 0);
|
||
state.wireDeviceId = id || null;
|
||
const list = $("#sw-nic-list");
|
||
const pick = $("#sw-wire-nic-pick");
|
||
const input = $("#sw-wire-nic");
|
||
if (list) list.innerHTML = "";
|
||
if (pick) {
|
||
pick.innerHTML = id
|
||
? `<option value="">Loading NIC ports from OME…</option>`
|
||
: `<option value="">— select a server first —</option>`;
|
||
}
|
||
if (!id) return;
|
||
try {
|
||
const data = await api("GET", `/api/devices/${id}/nics`);
|
||
const rows = data.nics || [];
|
||
if (pick) {
|
||
pick.innerHTML =
|
||
`<option value="">— ${rows.length ? "select NIC port" : "no NIC ports in OME"} —</option>` +
|
||
rows
|
||
.map((n) => {
|
||
const val = n.port_id || n.fqdd || n.name || "";
|
||
const label = n.label || [n.port_id || n.fqdd, n.name, n.mac].filter(Boolean).join(" · ");
|
||
return `<option value="${escape(val)}" title="${escape(label)}">${escape(label)}</option>`;
|
||
})
|
||
.join("");
|
||
if (input?.value) {
|
||
const match = [...pick.options].find((o) => o.value && o.value === input.value);
|
||
if (match) pick.value = match.value;
|
||
}
|
||
pick.onchange = () => {
|
||
if (pick.value && input) input.value = pick.value;
|
||
};
|
||
}
|
||
rows.forEach((n) => {
|
||
if (!list) return;
|
||
const opt = document.createElement("option");
|
||
const val = n.port_id || n.fqdd || n.name || "";
|
||
opt.value = val;
|
||
opt.label = n.label || val;
|
||
list.appendChild(opt);
|
||
});
|
||
if (rows[0] && input && !input.value) {
|
||
const first = rows[0].port_id || rows[0].fqdd || rows[0].name || "";
|
||
input.value = first;
|
||
if (pick) pick.value = first;
|
||
}
|
||
} catch (_) {
|
||
if (pick) pick.innerHTML = `<option value="">NIC lookup failed — type PortId manually</option>`;
|
||
}
|
||
}
|
||
|
||
async function saveWire() {
|
||
const port = state.selectedPort;
|
||
const deviceId = Number($("#sw-wire-device")?.value || 0);
|
||
const nic = ($("#sw-wire-nic")?.value || "").trim();
|
||
const note = ($("#sw-wire-note")?.value || "").trim();
|
||
if (!state.selectedSwitchId || !port) return;
|
||
if (!deviceId) {
|
||
alert("Select a server / endpoint");
|
||
return;
|
||
}
|
||
try {
|
||
state.portmap = await api("PUT", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`, {
|
||
switch_port: port,
|
||
device_id: deviceId,
|
||
device_port: nic,
|
||
note,
|
||
});
|
||
renderFabric();
|
||
startPulse();
|
||
} catch (e) {
|
||
alert("Save failed: " + e.message);
|
||
}
|
||
}
|
||
|
||
async function clearWire() {
|
||
const port = state.selectedPort;
|
||
if (!state.selectedSwitchId || !port) return;
|
||
if (!confirm(`Unwire switch port ${port}?`)) return;
|
||
try {
|
||
state.portmap = await api("DELETE", `/api/network/switches/${state.selectedSwitchId}/ports/${port}`);
|
||
renderFabric();
|
||
startPulse();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
function drawCables() {
|
||
const canvas = $("#sw-cable-canvas");
|
||
const grid = $("#sw-port-grid");
|
||
const rail = $("#sw-peer-rail");
|
||
const stage = $("#sw-cable-stage") || canvas?.parentElement;
|
||
if (!canvas || !grid || !stage || state.tab !== "fabric") return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = stage.clientWidth;
|
||
const h = stage.clientHeight;
|
||
if (w < 10 || h < 10) return;
|
||
canvas.width = Math.floor(w * dpr);
|
||
canvas.height = Math.floor(h * dpr);
|
||
canvas.style.width = w + "px";
|
||
canvas.style.height = h + "px";
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
const stageBox = stage.getBoundingClientRect();
|
||
const t = state.anim;
|
||
const ports = state.portmap?.ports || [];
|
||
|
||
ports.forEach((p) => {
|
||
if (!p.wired) return;
|
||
const portBtn = grid.querySelector(`[data-port="${p.port}"]`);
|
||
const peer = rail?.querySelector(`[data-peer-port="${p.port}"]`);
|
||
if (!portBtn || !peer) return;
|
||
const a = portBtn.getBoundingClientRect();
|
||
const b = peer.getBoundingClientRect();
|
||
/* Attach to port bottom-center → peer left-center (all in stage coords) */
|
||
const x1 = a.left + a.width / 2 - stageBox.left;
|
||
const y1 = a.bottom - stageBox.top;
|
||
const x2 = b.left - stageBox.left;
|
||
const y2 = b.top + b.height / 2 - stageBox.top;
|
||
if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return;
|
||
|
||
const live = !!p.link?.device?.connected;
|
||
const col = getCableColor(p.port);
|
||
const dy = Math.max(28, y2 - y1);
|
||
const c1x = x1;
|
||
const c1y = y1 + dy * 0.45;
|
||
const c2x = x2 - Math.min(80, Math.max(36, (x2 - x1) * 0.35));
|
||
const c2y = y2;
|
||
|
||
/* Base dashed cable — user-chosen color */
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
|
||
ctx.strokeStyle = hexToRgba(col, live ? 0.72 : 0.4);
|
||
ctx.lineWidth = live ? 2.6 : 2.2;
|
||
ctx.lineCap = "round";
|
||
ctx.setLineDash([7, 6]);
|
||
ctx.lineDashOffset = -t * (live ? 50 : 28);
|
||
ctx.stroke();
|
||
|
||
/* Glow pulse */
|
||
const pulse = (Math.sin(t * 5 + p.port) + 1) / 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
|
||
ctx.strokeStyle = hexToRgba(col, (live ? 0.22 : 0.1) + pulse * (live ? 0.5 : 0.25));
|
||
ctx.lineWidth = live ? 3.4 : 2.8;
|
||
ctx.setLineDash([]);
|
||
ctx.stroke();
|
||
|
||
/* Moving packet */
|
||
const u = (t * 0.35 + p.port * 0.07) % 1;
|
||
const pt = bezierPoint(x1, y1, c1x, c1y, c2x, c2y, x2, y2, u);
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, live ? 3.4 : 2.8, 0, Math.PI * 2);
|
||
ctx.fillStyle = col;
|
||
ctx.fill();
|
||
|
||
/* Anchor dots */
|
||
ctx.beginPath();
|
||
ctx.arc(x1, y1, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = col;
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.arc(x2, y2, 2.5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
});
|
||
}
|
||
|
||
function bezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
|
||
const u = 1 - t;
|
||
return {
|
||
x: u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3,
|
||
y: u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3,
|
||
};
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/* ===== Dell Rack Design Studio ===== */
|
||
const U_OVERVIEW = 7;
|
||
const U_STUDIO = 56; // full-bleed faceplate height per U
|
||
|
||
function roleOf(it) {
|
||
return (it.category || it.catalog?.category || it.device?.role || it.face || "custom").toLowerCase();
|
||
}
|
||
function roleClass(role) {
|
||
const r = (role || "").toLowerCase();
|
||
if (r.includes("switch")) return "switch";
|
||
if (r.includes("storage")) return "storage";
|
||
if (r.includes("chassis")) return "chassis";
|
||
if (r.includes("pdu")) return "pdu";
|
||
if (r.includes("blank")) return "blank";
|
||
if (r.includes("server") || r.includes("blade") || r.includes("vxrail")) return "server";
|
||
return "custom";
|
||
}
|
||
/** u_start = bottom U; device fills u_start .. u_start+h-1 (higher U = toward top of rack). */
|
||
function uSpan(uStart, h) {
|
||
const lo = Math.max(1, Number(uStart) || 1);
|
||
const height = Math.max(1, Number(h) || 1);
|
||
const hi = lo + height - 1;
|
||
return { lo, hi, height, label: height === 1 ? `U${lo}` : `U${lo}–U${hi}` };
|
||
}
|
||
function catalogHeight(sku, fallback = 1) {
|
||
const cat = (state.racks?.catalog || []).find((c) => c.sku === sku);
|
||
const h = Number(cat?.u_height);
|
||
return h > 0 ? h : Math.max(1, Number(fallback) || 1);
|
||
}
|
||
function unitsFree(rk, uStart, h, excludeItemId) {
|
||
const side = state.rackSide || "front";
|
||
const units = rk?.units || 42;
|
||
const span = uSpan(uStart, h);
|
||
if (span.hi > units) return false;
|
||
const items = (rk?.items || []).filter((it) => (it.side || "front") === side);
|
||
for (const it of items) {
|
||
if (excludeItemId != null && (it.id || it.item_id) === excludeItemId) continue;
|
||
const a = it.u_start;
|
||
const b = it.u_start + Math.max(1, it.u_height || 1) - 1;
|
||
if (!(span.hi < a || span.lo > b)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
function rackFillPct(rk) {
|
||
const items = (rk.items || []).filter((it) => (it.side || "front") === "front");
|
||
const used = items.reduce((a, p) => a + Math.max(0, p.u_height || 0), 0);
|
||
const units = rk.units || 42;
|
||
return { used, free: Math.max(0, units - used), pct: Math.round((used / units) * 100), units };
|
||
}
|
||
function allRacksList() {
|
||
const sites = state.racks?.sites || [];
|
||
const show = state.site === "Both" ? sites : sites.filter((s) => s.id === state.site);
|
||
return show.flatMap((s) => (s.racks || []).map((r) => ({ ...r, siteName: s.name })));
|
||
}
|
||
function focusRack() {
|
||
const all = allRacksList();
|
||
return all.find((r) => r.id === state.selectedRackId) || all[0] || null;
|
||
}
|
||
|
||
let _faceSeq = 0;
|
||
function faceSvg(face, label, hU, opts = {}) {
|
||
const px = opts.px || U_STUDIO;
|
||
const h = Math.max(px, Math.max(1, hU || 1) * px);
|
||
const w = opts.w || 420;
|
||
const compact = !!opts.compact;
|
||
const title = escape((label || "").slice(0, 36));
|
||
const gid = "fg" + (++_faceSeq);
|
||
const cat = (face || "").toLowerCase();
|
||
|
||
if (cat.startsWith("switch")) {
|
||
const n = compact ? 16 : 24;
|
||
let ports = "";
|
||
for (let i = 0; i < n; i++) {
|
||
const x = 40 + i * ((w - 70) / n);
|
||
ports += `<rect x="${x}" y="${h * 0.22}" width="${(w - 70) / n - 1.4}" height="${h * 0.56}" rx="1" fill="#040c12" stroke="#6ecfff" stroke-width="0.7"/>`;
|
||
}
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
||
<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1e5570"/><stop offset="100%" stop-color="#0f3144"/></linearGradient></defs>
|
||
<rect width="${w}" height="${h}" rx="3" fill="url(#${gid})"/>
|
||
<text x="8" y="${h * 0.62}" fill="#b6e7ff" font-size="${compact ? 6 : 9}" font-family="ui-monospace,monospace">DELL</text>
|
||
${ports}<circle cx="${w - 12}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#3dffe0"/>
|
||
</svg>`;
|
||
}
|
||
if (cat.startsWith("storage")) {
|
||
const cols = compact ? 10 : 12;
|
||
let bays = "";
|
||
for (let i = 0; i < cols; i++) {
|
||
bays += `<rect x="${10 + i * ((w - 20) / cols)}" y="3" width="${(w - 20) / cols - 1.5}" height="${h - 6}" rx="1.5" fill="#1a1008" stroke="#e08a40" stroke-width="0.7"/>`;
|
||
}
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><rect width="${w}" height="${h}" rx="3" fill="#2a180c"/>${bays}</svg>`;
|
||
}
|
||
if (cat.includes("chassis") || cat === "chassis_mx") {
|
||
const n = compact ? 5 : 8;
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
||
<rect width="${w}" height="${h}" rx="3" fill="#1a1430"/>
|
||
${Array.from({ length: n }, (_, i) => `<rect x="${8 + i * ((w - 16) / n)}" y="4" width="${(w - 16) / n - 3}" height="${h - 8}" rx="2" fill="#2a2150" stroke="#9b8cff" stroke-width="0.7"/>`).join("")}
|
||
</svg>`;
|
||
}
|
||
if (cat.startsWith("pdu")) {
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
||
<rect width="${w}" height="${h}" rx="2" fill="#2c2c2c"/>
|
||
${Array.from({ length: compact ? 8 : 12 }, (_, i) => `<circle cx="${18 + i * ((w - 30) / (compact ? 8 : 12))}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#111" stroke="#bbb"/>`).join("")}
|
||
</svg>`;
|
||
}
|
||
if (cat.startsWith("blank")) {
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><rect width="${w}" height="${h}" rx="2" fill="#141a22" stroke="#2e3742"/></svg>`;
|
||
}
|
||
const drives = cat.includes("4u") ? 24 : cat.includes("2u") ? 12 : 8;
|
||
const n = compact ? Math.min(drives, 10) : drives;
|
||
let disks = "";
|
||
for (let i = 0; i < n; i++) {
|
||
disks += `<rect x="${36 + i * ((w - 56) / n)}" y="${h * 0.16}" width="${(w - 56) / n - 1.5}" height="${h * 0.68}" rx="1" fill="#071820" stroke="#3dffe0" stroke-width="0.55"/>`;
|
||
}
|
||
return `<svg class="ru-face" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
||
<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1c4450"/><stop offset="100%" stop-color="#0c2834"/></linearGradient></defs>
|
||
<rect width="${w}" height="${h}" rx="3" fill="url(#${gid})"/>
|
||
<text x="6" y="${compact ? h * 0.55 : 11}" fill="#9fe" font-size="${compact ? 6 : 8}" font-family="ui-monospace,monospace">PowerEdge</text>
|
||
${disks}<circle cx="${w - 10}" cy="${h / 2}" r="${compact ? 2 : 3}" fill="#3dffe0"/>
|
||
${compact ? "" : `<text x="6" y="${h - 3}" fill="#cfe" font-size="7">${title}</text>`}
|
||
</svg>`;
|
||
}
|
||
|
||
function ensureStudioRoot() {
|
||
let el = $("#rack-studio");
|
||
if (el) return el;
|
||
el = document.createElement("div");
|
||
el.id = "rack-studio";
|
||
el.className = "rack-studio";
|
||
el.setAttribute("aria-hidden", "true");
|
||
document.body.appendChild(el);
|
||
return el;
|
||
}
|
||
|
||
function openStudio(rackId) {
|
||
if (rackId != null) state.selectedRackId = rackId;
|
||
state.studioOpen = true;
|
||
state.studioZoom = state.studioZoom || 1;
|
||
state.rackSide = state.rackSide || "front";
|
||
state.libTab = state.libTab || "catalog";
|
||
state.catalogFamily = state.catalogFamily || "PowerEdge";
|
||
renderRacks(); // keep overview in sync
|
||
renderStudio();
|
||
}
|
||
|
||
function closeStudio() {
|
||
state.studioOpen = false;
|
||
state.placeDeviceId = null;
|
||
state.placeSku = null;
|
||
const el = $("#rack-studio");
|
||
if (el) {
|
||
if (el._onKey) document.removeEventListener("keydown", el._onKey);
|
||
el._onKey = null;
|
||
el.classList.remove("open");
|
||
el.setAttribute("aria-hidden", "true");
|
||
el.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
|
||
function productPhoto(itOrFace, label, opts = {}) {
|
||
const img =
|
||
(typeof itOrFace === "object" && itOrFace
|
||
? itOrFace.image || itOrFace.catalog?.image || null
|
||
: null) ||
|
||
opts.image ||
|
||
null;
|
||
const face =
|
||
typeof itOrFace === "string"
|
||
? itOrFace
|
||
: itOrFace?.face || itOrFace?.catalog?.face || "server_1u";
|
||
const fallback = {
|
||
server_1u: "/assets/dell/face-server-1u.jpg?v=bezel2",
|
||
server_2u: "/assets/dell/face-server-2u.jpg?v=bezel2",
|
||
server_4u: "/assets/dell/face-server-4u.jpg?v=bezel2",
|
||
switch_1u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
|
||
switch_2u: "/assets/dell/face-switch-1u.jpg?v=bezel2",
|
||
storage_2u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
|
||
storage_3u: "/assets/dell/face-storage-2u.jpg?v=bezel2",
|
||
chassis_mx: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
|
||
pdu_1u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
|
||
pdu_0u: "/assets/dell/face-pdu-1u.jpg?v=bezel2",
|
||
blank_1u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
|
||
blank_2u: "/assets/dell/face-blank-1u.jpg?v=bezel2",
|
||
blade: "/assets/dell/face-chassis-mx.jpg?v=bezel2",
|
||
fabric: "/assets/dell/face-switch-1u.jpg?v=bezel2",
|
||
custom: "/assets/dell/face-server-1u.jpg?v=bezel2",
|
||
};
|
||
const src = img || fallback[face] || fallback.custom;
|
||
const cls = opts.compact ? "ru-photo compact" : "ru-photo";
|
||
return `<img class="${cls}" src="${escape(src)}" alt="${escape(label || "")}" loading="lazy" draggable="false" />`;
|
||
}
|
||
|
||
function shortModel(name) {
|
||
const s = String(name || "");
|
||
const m = s.match(/\b(R\d{3,4}\w*|MX\d+\w*|S\d{4}\w*|N\d{4}\w*|Z\d{4}\w*|ME\d{4}|DD\d{4}|T\d{3}|XE\d+|C\d{4}\w*|XR\d+|PC-?\d+|PowerConnect\s*\d+|PowerSwitch\s*\S+|PowerVault\s*\S+|VxRail\s*\S+)\b/i);
|
||
if (m) return m[1].replace(/^Power(Edge|Switch|Vault|Connect)\s*/i, "").trim();
|
||
return s.replace(/^PowerEdge\s+/i, "").slice(0, 14) || "Device";
|
||
}
|
||
|
||
function tintFor(key) {
|
||
let h = 0;
|
||
const s = String(key || "");
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||
return h % 360;
|
||
}
|
||
|
||
function facePlate(p) {
|
||
const face = p.face || p.catalog?.face || "server_1u";
|
||
const label = p.label || p.device?.name || p.catalog?.name || "Device";
|
||
const model = p.catalog?.name || p.device?.model || label;
|
||
const code = shortModel(model);
|
||
const hU = p.u_height || 1;
|
||
const rc = roleClass(roleOf(p));
|
||
const sku = p.catalog_sku || p.catalog?.sku || model;
|
||
const hue = tintFor(sku);
|
||
const meta = [
|
||
hU > 1 ? uSpan(p.u_start, hU).label : null,
|
||
hU ? `${hU}U` : null,
|
||
p.device?.service_tag ? `ST ${p.device.service_tag}` : null,
|
||
p.catalog?.category || p.category || rc,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return `<div class="rs-u-face role-${rc}" style="--face-hue:${hue}">
|
||
${productPhoto({ face, image: p.image || p.catalog?.image }, label)}
|
||
<div class="rs-u-plate">
|
||
<span class="rs-u-code">${escape(code)}</span>
|
||
<strong>${escape(label)}</strong>
|
||
<span>${escape(meta)}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function miniElevation(rk) {
|
||
const units = rk.units || 42;
|
||
const items = (rk.items || []).filter((it) => (it.side || "front") === "front");
|
||
const occ = new Array(units + 1).fill(null);
|
||
items.forEach((p) => {
|
||
for (let u = p.u_start; u < p.u_start + p.u_height && u <= units; u++) occ[u] = p;
|
||
});
|
||
const rows = [];
|
||
for (let u = units; u >= 1; u--) {
|
||
const p = occ[u];
|
||
const isStart = p && p.u_start === u;
|
||
if (p && !isStart) continue;
|
||
if (p && isStart) {
|
||
rows.push(`<div class="ov-slot filled role-${roleClass(roleOf(p))}" style="height:${Math.max(3, p.u_height * U_OVERVIEW)}px" title="${escape(p.label || "")} · ${uSpan(p.u_start, p.u_height).label}"></div>`);
|
||
} else {
|
||
rows.push(`<div class="ov-slot empty" style="height:${U_OVERVIEW}px"></div>`);
|
||
}
|
||
}
|
||
const fill = rackFillPct(rk);
|
||
return `<button type="button" class="ov-rack" data-open-studio="${rk.id}">
|
||
<div class="ov-rack-top"><strong>${escape(rk.name)}</strong><span>${fill.used}/${fill.units}U · ${fill.pct}%</span></div>
|
||
<div class="ov-rack-body">${rows.join("")}</div>
|
||
<div class="ov-rack-cta">Open Design Studio</div>
|
||
</button>`;
|
||
}
|
||
|
||
function renderRacks() {
|
||
const root = $("#network-racks");
|
||
if (!root || !state.racks) return;
|
||
const allRacks = allRacksList();
|
||
if (!state.selectedRackId && allRacks[0]) state.selectedRackId = allRacks[0].id;
|
||
|
||
root.innerHTML = `
|
||
<div class="rd-bar">
|
||
<div class="rd-bar-left">
|
||
<div class="chip-grid">
|
||
${["ATC1", "ATC2", "Both"].map((s) => `<button type="button" class="chip ${state.site === s ? "active" : ""}" data-site="${s}">${s}</button>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="rd-bar-right">
|
||
<button type="button" class="btn primary" id="rd-open-studio" ${allRacks.length ? "" : "disabled"}>Design Studio</button>
|
||
<button type="button" class="btn" id="net-refresh-racks">Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div class="rd-overview">
|
||
<header class="rd-overview-head">
|
||
<div>
|
||
<h3>Rack floor · ${escape(state.site === "Both" ? "ATC1 + ATC2" : state.site)}</h3>
|
||
<p>Click a rack to open Design Studio (zoom, all 42U, Dell catalog).</p>
|
||
</div>
|
||
<div class="rd-legend">
|
||
<span class="lg server">Server</span><span class="lg switch">Switch</span>
|
||
<span class="lg storage">Storage</span><span class="lg chassis">Chassis</span><span class="lg pdu">PDU</span>
|
||
</div>
|
||
</header>
|
||
<div class="rd-gallery">
|
||
${allRacks.map((rk) => miniElevation(rk)).join("") || '<p class="hint">Geen racks</p>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
root.querySelectorAll("[data-site]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
state.site = btn.dataset.site;
|
||
state.selectedRackId = null;
|
||
renderRacks();
|
||
})
|
||
);
|
||
root.querySelectorAll("[data-open-studio]").forEach((btn) =>
|
||
btn.addEventListener("click", () => openStudio(Number(btn.dataset.openStudio)))
|
||
);
|
||
$("#rd-open-studio")?.addEventListener("click", () => openStudio(state.selectedRackId));
|
||
$("#net-refresh-racks")?.addEventListener("click", () => refresh());
|
||
|
||
if (state.studioOpen) renderStudio();
|
||
}
|
||
|
||
function renderStudio() {
|
||
const el = ensureStudioRoot();
|
||
const rk = focusRack();
|
||
const catalog = state.racks?.catalog || [];
|
||
const families = [...new Set(catalog.map((c) => c.family))];
|
||
const unplaced = state.racks?.unplaced || [];
|
||
const allRacks = allRacksList();
|
||
if (!rk) {
|
||
el.innerHTML = `<div class="rs-backdrop"></div><div class="rs-panel"><p class="hint">Geen rack</p><button class="btn" id="rs-close">Close</button></div>`;
|
||
el.classList.add("open");
|
||
$("#rs-close")?.addEventListener("click", closeStudio);
|
||
return;
|
||
}
|
||
state.studioZoom = Math.min(1.8, Math.max(0.7, state.studioZoom || 1.15));
|
||
const q = (state.libQuery || "").toLowerCase().trim();
|
||
const filteredCat = catalog
|
||
.filter((c) => c.family === state.catalogFamily)
|
||
.filter((c) => !q || [c.name, c.sku, c.category].join(" ").toLowerCase().includes(q));
|
||
const filteredUn = unplaced.filter(
|
||
(d) => !q || [d.name, d.model, d.service_tag, d.role, d.ip].join(" ").toLowerCase().includes(q)
|
||
);
|
||
const placing = !!(state.placeDeviceId || state.placeSku);
|
||
const placeLabel = state.placeSku
|
||
? catalog.find((c) => c.sku === state.placeSku)?.name || state.placeSku
|
||
: unplaced.find((d) => d.id === state.placeDeviceId)?.name || "";
|
||
|
||
el.innerHTML = `
|
||
<div class="rs-backdrop" data-rs-close></div>
|
||
<div class="rs-shell" role="dialog" aria-modal="true" aria-label="Dell Rack Design Studio">
|
||
<header class="rs-head">
|
||
<div class="rs-brand">
|
||
<img src="/dell.png" alt="" height="20" />
|
||
<div>
|
||
<strong>Dell Rack Design Studio</strong>
|
||
<span>${escape(rk.name)} · ${escape(rk.siteName || "")} · Visio design · drag & drop</span>
|
||
</div>
|
||
</div>
|
||
<div class="rs-head-actions">
|
||
<div class="chip-grid">
|
||
${allRacks.map((r) => `<button type="button" class="chip ${r.id === rk.id ? "active" : ""}" data-rs-rack="${r.id}">${escape(r.name)}</button>`).join("")}
|
||
</div>
|
||
<div class="chip-grid">
|
||
${["front", "rear"].map((s) => `<button type="button" class="chip ${state.rackSide === s ? "active" : ""}" data-rs-side="${s}">${s}</button>`).join("")}
|
||
</div>
|
||
<div class="rs-zoom">
|
||
<button type="button" class="btn" data-rs-zoom="-">−</button>
|
||
<span>${Math.round(state.studioZoom * 100)}%</span>
|
||
<button type="button" class="btn" data-rs-zoom="+">+</button>
|
||
<button type="button" class="btn" data-rs-zoom="fit">Fit</button>
|
||
</div>
|
||
<button type="button" class="btn ghost" id="rs-close">Close</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="rs-body">
|
||
<aside class="rs-lib">
|
||
<div class="rs-lib-tabs">
|
||
<button type="button" class="${state.libTab === "catalog" ? "active" : ""}" data-libtab="catalog">Dell products</button>
|
||
<button type="button" class="${state.libTab === "unplaced" ? "active" : ""}" data-libtab="unplaced">OME (${unplaced.length})</button>
|
||
</div>
|
||
<input type="search" id="rs-lib-q" placeholder="Search SKU / model…" value="${escape(state.libQuery || "")}" />
|
||
${
|
||
state.libTab === "catalog"
|
||
? `<div class="chip-grid rs-fam">${families
|
||
.map((f) => `<button type="button" class="chip ${state.catalogFamily === f ? "active" : ""}" data-fam="${escape(f)}">${escape(f)}</button>`)
|
||
.join("")}</div>
|
||
<div class="rs-lib-list">${filteredCat
|
||
.map(
|
||
(c) => `<div class="rs-stencil ${state.placeSku === c.sku ? "active" : ""}" draggable="true"
|
||
data-sku="${escape(c.sku)}" data-h="${c.u_height > 0 ? c.u_height : 1}" data-face="${escape(c.face || "")}" data-img="${escape(c.image || "")}" data-name="${escape(c.name)}">
|
||
<span class="rs-stencil-grip" aria-hidden="true"></span>
|
||
<span class="rs-prod-face">${productPhoto(c, c.name, { compact: true })}</span>
|
||
<span class="rs-prod-meta"><strong>${escape(shortModel(c.name))}</strong> ${escape(c.name)}<em>${c.u_height || 0}U · ${escape((c.face || "").replace(/_/g, " "))} · drag</em></span>
|
||
</div>`
|
||
)
|
||
.join("") || '<p class="hint">No products</p>'}</div>`
|
||
: `<div class="rs-lib-list">${filteredUn
|
||
.map(
|
||
(d) => `<div class="rs-stencil ${state.placeDeviceId === d.id ? "active" : ""}" draggable="true"
|
||
data-place="${d.id}" data-h="${d.default_u_height || 1}" data-face="${escape(d.face || "")}" data-img="${escape(d.image || "")}" data-name="${escape(d.name)}" data-sku="${escape(d.catalog_sku || "")}">
|
||
<span class="rs-stencil-grip" aria-hidden="true"></span>
|
||
<span class="rs-prod-face">${productPhoto({ face: d.face, image: d.image }, d.name, { compact: true })}</span>
|
||
<span class="rs-prod-meta"><strong>${escape(d.name)}</strong><em>${escape(d.model || "")} · ${d.default_u_height}U · drag</em></span>
|
||
</div>`
|
||
)
|
||
.join("") || '<p class="hint">All placed</p>'}</div>`
|
||
}
|
||
<p class="rs-lib-hint">Visio-stijl: sleep een product naar een vrije U in het rack</p>
|
||
</aside>
|
||
|
||
<main class="rs-canvas-wrap">
|
||
<div class="rs-statusbar">
|
||
<span id="rs-status-text">Drag stencils from the library onto a U</span>
|
||
<span class="rs-status-side">${escape((state.rackSide || "front").toUpperCase())} view</span>
|
||
</div>
|
||
<div class="rs-canvas visio" id="rs-canvas">
|
||
<div class="rs-zoom-layer" id="rs-zoom-layer" style="transform:scale(${state.studioZoom}); transform-origin: top center;">
|
||
${renderStudioRack(rk)}
|
||
</div>
|
||
</div>
|
||
<div id="rs-drag-ghost" class="rs-drag-ghost" hidden></div>
|
||
</main>
|
||
|
||
<aside class="rs-insp">
|
||
${renderStudioInspector(rk)}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
`;
|
||
el.classList.add("open");
|
||
el.setAttribute("aria-hidden", "false");
|
||
bindStudio(el, catalog, unplaced);
|
||
// scroll so top (U42) is visible; user can scroll full height
|
||
const canvas = $("#rs-canvas", el);
|
||
if (canvas && state.studioScrollTop != null) canvas.scrollTop = state.studioScrollTop;
|
||
}
|
||
|
||
|
||
function bindStudio(el, catalog, unplaced) {
|
||
const status = () => $("#rs-status-text", el);
|
||
const setStatus = (msg) => {
|
||
if (status()) status().textContent = msg;
|
||
};
|
||
|
||
$("#rs-close", el)?.addEventListener("click", closeStudio);
|
||
el.querySelector("[data-rs-close]")?.addEventListener("click", closeStudio);
|
||
el.querySelectorAll("[data-rs-rack]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
state.selectedRackId = Number(btn.dataset.rsRack);
|
||
renderStudio();
|
||
})
|
||
);
|
||
el.querySelectorAll("[data-rs-side]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
state.rackSide = btn.dataset.rsSide;
|
||
renderStudio();
|
||
})
|
||
);
|
||
el.querySelectorAll("[data-rs-zoom]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
const z = btn.dataset.rsZoom;
|
||
if (z === "+") state.studioZoom = Math.min(1.8, (state.studioZoom || 1) + 0.1);
|
||
else if (z === "-") state.studioZoom = Math.max(0.7, (state.studioZoom || 1) - 0.1);
|
||
else state.studioZoom = 1;
|
||
renderStudio();
|
||
})
|
||
);
|
||
el.querySelectorAll("[data-libtab]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
state.libTab = btn.dataset.libtab;
|
||
renderStudio();
|
||
})
|
||
);
|
||
el.querySelectorAll("[data-fam]").forEach((btn) =>
|
||
btn.addEventListener("click", () => {
|
||
state.catalogFamily = btn.dataset.fam;
|
||
renderStudio();
|
||
})
|
||
);
|
||
|
||
const qEl = $("#rs-lib-q", el);
|
||
qEl?.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
state.libQuery = qEl.value;
|
||
renderStudio();
|
||
}
|
||
});
|
||
qEl?.addEventListener("change", () => {
|
||
state.libQuery = qEl.value;
|
||
renderStudio();
|
||
});
|
||
|
||
const canvas = $("#rs-canvas", el);
|
||
canvas?.addEventListener("scroll", () => {
|
||
state.studioScrollTop = canvas.scrollTop;
|
||
});
|
||
|
||
el.querySelectorAll("[data-item-del]").forEach((btn) =>
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
if (!confirm("Remove from rack?")) return;
|
||
state.racks = await api("DELETE", `/api/racks/items/${Number(btn.dataset.itemDel)}`);
|
||
renderRacks();
|
||
renderStudio();
|
||
})
|
||
);
|
||
el.querySelectorAll("[data-item-sel]").forEach((btn) =>
|
||
btn.addEventListener("click", (e) => {
|
||
if (e.target.closest("[data-item-del]")) return;
|
||
if (state._dragMoved) return;
|
||
state.selectedItemId = Number(btn.dataset.itemSel);
|
||
renderStudio();
|
||
})
|
||
);
|
||
|
||
/* ---- Visio-style drag & drop ---- */
|
||
const ghost = $("#rs-drag-ghost", el);
|
||
let dragPayload = null;
|
||
|
||
function clearDropHighlight() {
|
||
el.querySelectorAll(".rs-u.drop-over, .rs-u.drop-span, .rs-u.drop-bad").forEach((n) => {
|
||
n.classList.remove("drop-over", "drop-span", "drop-bad");
|
||
});
|
||
}
|
||
|
||
function highlightSpan(uStart, h, ok) {
|
||
clearDropHighlight();
|
||
const span = uSpan(uStart, h);
|
||
for (let u = span.lo; u <= span.hi; u++) {
|
||
const zone = el.querySelector(`.rs-u.empty[data-u="${u}"]`);
|
||
if (!zone) continue;
|
||
zone.classList.add(ok ? "drop-span" : "drop-bad");
|
||
if (u === uStart) zone.classList.add("drop-over");
|
||
}
|
||
el.classList.add("rs-dragging");
|
||
return span;
|
||
}
|
||
|
||
function parseDrag(raw) {
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveHeight(payload) {
|
||
if (!payload) return state.placeH || 1;
|
||
if (payload.sku) return catalogHeight(payload.sku, payload.u_height || 1);
|
||
return Math.max(1, Number(payload.u_height) || state.placeH || 1);
|
||
}
|
||
|
||
function stencilDragStart(node, e) {
|
||
const sku = node.dataset.sku || null;
|
||
const deviceId = node.dataset.place ? Number(node.dataset.place) : null;
|
||
const h = Number(node.dataset.h || 1);
|
||
const catHit = (sku && catalog.find((c) => c.sku === sku)) || null;
|
||
const unHit = deviceId ? unplaced.find((d) => d.id === deviceId) : null;
|
||
const name = node.dataset.name || catHit?.name || unHit?.name || "Device";
|
||
const face = node.dataset.face || catHit?.face || unHit?.face || "server_1u";
|
||
const image = node.dataset.img || catHit?.image || unHit?.image || "";
|
||
const uHeight = sku
|
||
? catalogHeight(sku, h)
|
||
: h > 0
|
||
? h
|
||
: catHit?.u_height > 0
|
||
? catHit.u_height
|
||
: 1;
|
||
dragPayload = {
|
||
kind: "stencil",
|
||
sku: sku || null,
|
||
device_id: deviceId,
|
||
u_height: uHeight,
|
||
name,
|
||
image,
|
||
face,
|
||
};
|
||
state.placeSku = sku || null;
|
||
state.placeDeviceId = deviceId;
|
||
state.placeH = dragPayload.u_height;
|
||
e.dataTransfer.effectAllowed = "copy";
|
||
const raw = JSON.stringify(dragPayload);
|
||
e.dataTransfer.setData("application/x-rack-item", raw);
|
||
e.dataTransfer.setData("text/plain", raw);
|
||
if (ghost) {
|
||
ghost.hidden = false;
|
||
const code = shortModel(name);
|
||
ghost.innerHTML = `${productPhoto({ face, image }, name, { compact: true })}
|
||
<span class="rs-ghost-meta">
|
||
<span class="rs-ghost-code">${escape(code)}</span>
|
||
<strong>${escape(name)}</strong>
|
||
<span>${uHeight}U · ${escape(face.replace(/_/g, " "))}</span>
|
||
</span>`;
|
||
try {
|
||
e.dataTransfer.setDragImage(ghost, 48, 24);
|
||
} catch (_) {}
|
||
}
|
||
el.classList.add("rs-dragging");
|
||
setStatus(`Dragging ${name} (${uHeight}U) — drop on the bottom U (fills upward)`);
|
||
node.classList.add("dragging");
|
||
}
|
||
|
||
function itemDragStart(node, e) {
|
||
if (e.target.closest("[data-item-del]")) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
state._dragMoved = false;
|
||
const h = Math.max(1, Number(node.dataset.h || 1));
|
||
dragPayload = {
|
||
kind: "move",
|
||
item_id: Number(node.dataset.itemId),
|
||
u_height: h,
|
||
name: node.querySelector(".rs-u-plate strong")?.textContent || node.querySelector("strong")?.textContent || "Device",
|
||
};
|
||
e.dataTransfer.effectAllowed = "move";
|
||
const raw = JSON.stringify(dragPayload);
|
||
e.dataTransfer.setData("application/x-rack-item", raw);
|
||
e.dataTransfer.setData("text/plain", raw);
|
||
el.classList.add("rs-dragging");
|
||
setStatus(`Moving (${h}U): drop on a new bottom U`);
|
||
node.classList.add("dragging");
|
||
}
|
||
|
||
el.querySelectorAll(".rs-stencil").forEach((node) => {
|
||
node.addEventListener("dragstart", (e) => stencilDragStart(node, e));
|
||
node.addEventListener("dragend", () => {
|
||
node.classList.remove("dragging");
|
||
clearDropHighlight();
|
||
el.classList.remove("rs-dragging");
|
||
if (ghost) ghost.hidden = true;
|
||
dragPayload = null;
|
||
setStatus("Drag stencils from the library onto a bottom U");
|
||
});
|
||
// click still selects for keyboard users
|
||
node.addEventListener("click", () => {
|
||
if (node.dataset.sku) {
|
||
state.placeSku = node.dataset.sku;
|
||
state.placeDeviceId = null;
|
||
state.placeH = Number(node.dataset.h || 1);
|
||
} else if (node.dataset.place) {
|
||
state.placeDeviceId = Number(node.dataset.place);
|
||
state.placeSku = node.dataset.sku || null;
|
||
state.placeH = Number(node.dataset.h || 1);
|
||
}
|
||
setStatus(`Selected: ${node.dataset.name} — drag or click a U`);
|
||
el.querySelectorAll(".rs-stencil").forEach((n) => n.classList.toggle("active", n === node));
|
||
});
|
||
});
|
||
|
||
el.querySelectorAll(".rs-u.filled").forEach((node) => {
|
||
node.addEventListener("dragstart", (e) => itemDragStart(node, e));
|
||
node.addEventListener("dragend", () => {
|
||
node.classList.remove("dragging");
|
||
clearDropHighlight();
|
||
el.classList.remove("rs-dragging");
|
||
dragPayload = null;
|
||
setTimeout(() => {
|
||
state._dragMoved = false;
|
||
}, 0);
|
||
});
|
||
});
|
||
|
||
el.querySelectorAll("[data-drop-u]").forEach((zone) => {
|
||
zone.addEventListener("dragover", (e) => {
|
||
e.preventDefault();
|
||
const rk = focusRack();
|
||
const u = Number(zone.dataset.u);
|
||
const h = resolveHeight(dragPayload);
|
||
const exclude = dragPayload?.kind === "move" ? dragPayload.item_id : null;
|
||
const ok = unitsFree(rk, u, h, exclude);
|
||
e.dataTransfer.dropEffect = ok ? (dragPayload?.kind === "move" ? "move" : "copy") : "none";
|
||
const span = highlightSpan(u, h, ok);
|
||
if (ok) {
|
||
setStatus(`Drop → ${span.label} (${span.height}U)`);
|
||
} else {
|
||
setStatus(`Does not fit on ${span.label} — overlap or outside 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
|
||
? `<span class="rs-u-num">U${String(uLo).padStart(2, "0")}</span>`
|
||
: `<span class="rs-u-nums" title="U${uLo}–U${uHi} (${hU}U)">
|
||
${Array.from({ length: hU }, (_, i) => {
|
||
const uu = uHi - i;
|
||
return `<span class="rs-u-num">U${String(uu).padStart(2, "0")}</span>`;
|
||
}).join("")}
|
||
</span>`;
|
||
rows.push(`<div class="rs-u filled role-${rc} ${selected ? "sel" : ""}" draggable="true"
|
||
data-u="${u}" data-item-sel="${p.id || p.item_id}" data-item-id="${p.id || p.item_id}"
|
||
data-h="${hU}" style="height:${h}px" title="U${uLo}–U${uHi} · Drag to move · click to select">
|
||
${uMarks}
|
||
${facePlate(p)}
|
||
<button type="button" class="rs-u-x" data-item-del="${p.id || p.item_id}" title="Remove">×</button>
|
||
</div>`);
|
||
} else {
|
||
rows.push(`<div class="rs-u empty drop-zone" data-u="${u}" data-drop-u="1" style="height:${U_STUDIO}px">
|
||
<span class="rs-u-num">U${String(u).padStart(2, "0")}</span>
|
||
<span class="rs-u-ghost">drop</span>
|
||
</div>`);
|
||
}
|
||
}
|
||
const fill = rackFillPct(rk);
|
||
return `<div class="rs-cabinet">
|
||
<div class="rs-cab-head">
|
||
<div>
|
||
<strong>${escape(rk.name)}</strong>
|
||
<span>${side} · ${fill.used}U used · ${fill.free}U free · scroll for all 42U</span>
|
||
</div>
|
||
<div class="rs-side-pdu">${
|
||
sideItems.map((s) => `<span>${escape(s.side)}: ${escape(s.label || s.catalog?.name || "PDU")}</span>`).join("") ||
|
||
"<span class='muted'>No side PDUs — place 0U PDU on left/right</span>"
|
||
}</div>
|
||
</div>
|
||
<div class="rs-frame">
|
||
<div class="rs-rail" aria-hidden="true"></div>
|
||
<div class="rs-stack">${rows.join("")}</div>
|
||
<div class="rs-rail" aria-hidden="true"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderStudioInspector(rk) {
|
||
const side = state.rackSide || "front";
|
||
const items = [...(rk.items || [])]
|
||
.filter((it) => (it.side || "front") === side)
|
||
.sort((a, b) => b.u_start - a.u_start);
|
||
const fill = rackFillPct(rk);
|
||
const sel = items.find((i) => (i.id || i.item_id) === state.selectedItemId);
|
||
return `
|
||
<div class="rs-insp-head">
|
||
<strong>${escape(rk.name)}</strong>
|
||
<span>${items.length} items · ${fill.pct}% full</span>
|
||
</div>
|
||
<div class="rd-fillbar"><i style="width:${fill.pct}%"></i></div>
|
||
${
|
||
sel
|
||
? `<div class="rd-insp-card">
|
||
<h4>Selected</h4>
|
||
<strong>${escape(sel.label || "")}</strong>
|
||
<p>${escape(sel.catalog?.name || sel.device?.model || "")}</p>
|
||
<p class="mono">${uSpan(sel.u_start, sel.u_height).label} · ${escape(sel.side || "front")}</p>
|
||
${sel.device?.service_tag ? `<p class="mono">ST ${escape(sel.device.service_tag)}</p>` : ""}
|
||
${sel.device?.ip ? `<p class="mono">${escape(sel.device.ip)}</p>` : ""}
|
||
<button type="button" class="btn" data-item-del="${sel.id || sel.item_id}" style="margin-top:0.4rem">Remove</button>
|
||
</div>`
|
||
: `<p class="hint">Select a device in the rack for details</p>`
|
||
}
|
||
<h4 class="rd-insp-list-h">Rack contents (U42 → U1)</h4>
|
||
<div class="rd-insp-list">
|
||
${
|
||
items
|
||
.map(
|
||
(it) => `<button type="button" class="rd-insp-row ${state.selectedItemId === (it.id || it.item_id) ? "active" : ""}" data-item-sel="${it.id || it.item_id}">
|
||
<span class="rd-lib-swatch role-${roleClass(roleOf(it))}"></span>
|
||
<span><strong>${uSpan(it.u_start, it.u_height).label}</strong> ${escape(it.label || "")}</span>
|
||
</button>`
|
||
)
|
||
.join("") || '<p class="hint">Empty — pick from Dell products</p>'
|
||
}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ===== VLANs ===== */
|
||
function rackInfoForDevice(deviceId) {
|
||
if (deviceId == null || !state.racks?.sites) return null;
|
||
for (const site of state.racks.sites) {
|
||
for (const rk of site.racks || []) {
|
||
for (const it of rk.items || []) {
|
||
if (it.device_id === deviceId) {
|
||
const span = uSpan(it.u_start, it.u_height || 1);
|
||
return {
|
||
site: site.name || site.id,
|
||
rack: rk.name,
|
||
uLabel: span.label,
|
||
side: it.side || "front",
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function switchLinksForDevice(deviceId) {
|
||
const fromMember = [];
|
||
// Prefer API-enriched port_links (all switches), fall back to selected faceplate
|
||
const all = state.vlans?.port_links || [];
|
||
all
|
||
.filter((l) => l.device_id === deviceId)
|
||
.forEach((l) => fromMember.push({ port: l.switch_port, switch_id: l.switch_id }));
|
||
if (fromMember.length) return fromMember;
|
||
const ports = state.portmap?.ports || [];
|
||
return ports.filter((p) => p.wired && p.link?.device_id === deviceId);
|
||
}
|
||
|
||
function formatSwitchPorts(ports) {
|
||
if (!ports || !ports.length) return "none mapped";
|
||
return ports
|
||
.map((p) => {
|
||
const name = p.switch_name || (p.switch_id != null ? `sw ${p.switch_id}` : "switch");
|
||
const port = p.switch_port != null ? `P${p.switch_port}` : p.port != null ? `P${p.port}` : "port ?";
|
||
const hint = p.inferred ? " (hint)" : "";
|
||
return `${name} · ${port}${hint}`;
|
||
})
|
||
.join("; ");
|
||
}
|
||
|
||
function renderVlans() {
|
||
const root = $("#network-vlans");
|
||
if (!root || !state.vlans) return;
|
||
const vlans = state.vlans.vlans || [];
|
||
if (state.selectedVlanId == null && vlans[0]) state.selectedVlanId = vlans[0].id;
|
||
const sel = vlans.find((v) => v.id === state.selectedVlanId) || vlans[0];
|
||
const members = sel?.members || [];
|
||
const up = members.filter((m) => m.connected).length;
|
||
const roles = {};
|
||
members.forEach((m) => {
|
||
const r = (m.role || "other").toLowerCase();
|
||
roles[r] = (roles[r] || 0) + 1;
|
||
});
|
||
const matrix = state.vlans.attachment_matrix || [];
|
||
const matrixFocus = matrix.filter((row) => {
|
||
if (!sel) return true;
|
||
return (row.vlans || []).some((v) => v.vlan_id === sel.vlan_id || v.cidr === sel.cidr);
|
||
});
|
||
|
||
root.innerHTML = `
|
||
<div class="vlan-layout">
|
||
<aside class="vlan-list-pane">
|
||
<h4>VLAN / subnet catalog</h4>
|
||
<p class="hint">${escape(state.vlans.note || "")}</p>
|
||
<div class="vlan-cards">
|
||
${vlans
|
||
.map((v) => {
|
||
const active = v.id === state.selectedVlanId ? " active" : "";
|
||
return `<button type="button" class="vlan-card${active}" data-vlan="${v.id}" style="--vlan:${escape(v.color || "#ff9a3c")}">
|
||
<span class="vlan-id">VLAN ${v.vlan_id ?? "—"}</span>
|
||
<strong>${escape(v.name)}</strong>
|
||
<span class="mono">${escape(v.cidr)}</span>
|
||
<span class="vlan-card-meta">${v.member_count} nodes · ${v.connected_count} up · ${escape(v.site_id || "—")}</span>
|
||
</button>`;
|
||
})
|
||
.join("")}
|
||
</div>
|
||
${(state.vlans.discovered_subnets || []).length
|
||
? `<h4>Discovered (not in catalog)</h4>
|
||
<ul class="vlan-discovered">
|
||
${(state.vlans.discovered_subnets || [])
|
||
.map((d) => `<li><span class="mono">${escape(d.cidr)}</span> · ${d.device_ips} device IPs seen</li>`)
|
||
.join("")}
|
||
</ul>`
|
||
: ""}
|
||
</aside>
|
||
<section class="vlan-detail-pane">
|
||
${
|
||
sel
|
||
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#ff9a3c")}">
|
||
<div>
|
||
<h3>VLAN ${sel.vlan_id ?? "—"} · ${escape(sel.name)}</h3>
|
||
<p class="mono">${escape(sel.cidr)}</p>
|
||
<p class="vlan-purpose">${escape(sel.purpose || "No purpose set")} · site ${escape(sel.site_id || "—")}</p>
|
||
</div>
|
||
<div class="vlan-stat-stack">
|
||
<span class="vlan-stat">${members.length} nodes</span>
|
||
<span class="vlan-stat soft">${up} connected</span>
|
||
</div>
|
||
</header>
|
||
<div class="vlan-context">
|
||
<div class="vlan-kpi"><strong>${members.length}</strong><span>Endpoints</span></div>
|
||
<div class="vlan-kpi"><strong>${up}</strong><span>Online</span></div>
|
||
<div class="vlan-kpi"><strong>${members.length - up}</strong><span>Offline</span></div>
|
||
<div class="vlan-kpi"><strong>${Object.keys(roles).length}</strong><span>Roles</span></div>
|
||
</div>
|
||
${
|
||
Object.keys(roles).length
|
||
? `<div class="vlan-role-pills">${Object.entries(roles)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([r, n]) => `<span class="vlan-role-pill">${escape(r)} · ${n}</span>`)
|
||
.join("")}</div>`
|
||
: ""
|
||
}
|
||
<h4 class="vlan-nodes-h">Server → switch / VLAN map</h4>
|
||
<div class="vlan-matrix-wrap">
|
||
<table class="vlan-matrix">
|
||
<thead>
|
||
<tr>
|
||
<th>Server</th>
|
||
<th>Model</th>
|
||
<th>VLANs</th>
|
||
<th>Switch / port</th>
|
||
<th>Note</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${
|
||
(matrixFocus.length ? matrixFocus : matrix)
|
||
.slice(0, 120)
|
||
.map((row) => {
|
||
const vlanBits = (row.vlans || [])
|
||
.map(
|
||
(v) =>
|
||
`<span class="vlan-chip" style="--vlan:${escape(v.color || "#888")}">V${v.vlan_id ?? "?"} ${escape(v.name || "")}</span>`
|
||
)
|
||
.join(" ");
|
||
const note = row.user_note || row.endpoint_note || "";
|
||
return `<tr>
|
||
<td>
|
||
<strong>${escape(row.name)}</strong>
|
||
<div class="muted mono">${escape((row.ips || []).slice(0, 3).join(", "))}${row.service_tag ? " · " + escape(row.service_tag) : ""}</div>
|
||
</td>
|
||
<td>${escape(row.model || "—")}</td>
|
||
<td>${vlanBits || "—"}</td>
|
||
<td class="mono">${escape(formatSwitchPorts(row.switch_ports))}</td>
|
||
<td class="vlan-note-cell">${escape(note || "—")}</td>
|
||
</tr>`;
|
||
})
|
||
.join("") || `<tr><td colspan="5" class="hint">No server attachments yet.</td></tr>`
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<h4 class="vlan-nodes-h">Add / note a host</h4>
|
||
<form class="vlan-add-host" id="vlan-add-host">
|
||
<input name="hostname" placeholder="hostname" required />
|
||
<input name="ip" class="mono" placeholder="10.0.x.x" required />
|
||
<input name="model" placeholder="model (optional)" />
|
||
<input name="user_note" placeholder="manual note" />
|
||
<button type="submit" class="btn">Add host</button>
|
||
</form>
|
||
<h4 class="vlan-nodes-h">Coupled nodes</h4>
|
||
<div class="vlan-members">
|
||
${
|
||
members
|
||
.map((m) => {
|
||
const rack = rackInfoForDevice(m.id);
|
||
const links = m.switch_ports?.length ? m.switch_ports : switchLinksForDevice(m.id);
|
||
const src = m.source === "inventory" ? "inventory" : m.ome_name && m.ome_name !== m.name ? m.ome_name : "";
|
||
const eid = m.endpoint_id;
|
||
return `<article class="vlan-member ${m.connected ? "up" : "down"}" data-endpoint-id="${eid || ""}">
|
||
<div class="vlan-member-top">
|
||
<strong>${escape(m.name)}</strong>
|
||
<span class="vlan-badge ${m.connected ? "on" : "off"}">${m.connected ? "online" : "offline"}</span>
|
||
</div>
|
||
<div class="vlan-member-grid">
|
||
<span><em>Role</em> ${escape(m.role || "—")}</span>
|
||
<span><em>Model</em> ${escape(m.model || "—")}</span>
|
||
<span><em>Service Tag</em> <span class="mono">${escape(m.service_tag || "—")}</span></span>
|
||
<span><em>IPs in VLAN</em> <span class="mono">${escape((m.ips || []).join(", ") || "—")}</span></span>
|
||
<span><em>Rack</em> ${rack ? `${escape(rack.site)} / ${escape(rack.rack)} · ${rack.uLabel}` : "not placed"}</span>
|
||
<span><em>Switch ports</em> ${escape(formatSwitchPorts(links))}</span>
|
||
${src ? `<span><em>OME</em> ${escape(src)}</span>` : ""}
|
||
${m.endpoint_note ? `<span><em>System note</em> ${escape(m.endpoint_note)}</span>` : ""}
|
||
</div>
|
||
<div class="vlan-note-edit">
|
||
<label>Manual note</label>
|
||
<textarea data-note-for="${eid || ""}" rows="2" placeholder="${eid ? "Type a note and save…" : "Host not yet in inventory — use Add host above"}"${eid ? "" : " disabled"}>${escape(m.user_note || "")}</textarea>
|
||
<button type="button" class="btn ghost" data-save-note="${eid || ""}" ${eid ? "" : "disabled"}>Save note</button>
|
||
<span class="vlan-note-status" data-note-status="${eid || ""}"></span>
|
||
</div>
|
||
</article>`;
|
||
})
|
||
.join("") || '<p class="hint">No live members matched this CIDR yet.</p>'
|
||
}
|
||
</div>`
|
||
: '<p class="hint">No VLANs</p>'
|
||
}
|
||
</section>
|
||
</div>
|
||
`;
|
||
|
||
root.querySelectorAll("[data-vlan]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
state.selectedVlanId = Number(btn.dataset.vlan);
|
||
renderVlans();
|
||
});
|
||
});
|
||
|
||
root.querySelectorAll("[data-save-note]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const eid = Number(btn.dataset.saveNote);
|
||
if (!eid) return;
|
||
const ta = root.querySelector(`textarea[data-note-for="${eid}"]`);
|
||
const status = root.querySelector(`[data-note-status="${eid}"]`);
|
||
try {
|
||
if (status) status.textContent = "Saving…";
|
||
const res = await api("PATCH", `/api/network/endpoints/${eid}`, { user_note: ta?.value || "" });
|
||
if (res.vlans) state.vlans = res.vlans;
|
||
if (status) status.textContent = "Saved";
|
||
renderVlans();
|
||
} catch (e) {
|
||
if (status) status.textContent = "Error: " + e.message;
|
||
}
|
||
});
|
||
});
|
||
|
||
const addForm = root.querySelector("#vlan-add-host");
|
||
addForm?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(addForm);
|
||
const body = {
|
||
hostname: String(fd.get("hostname") || "").trim(),
|
||
ip: String(fd.get("ip") || "").trim(),
|
||
model: String(fd.get("model") || "").trim() || null,
|
||
user_note: String(fd.get("user_note") || "").trim() || null,
|
||
vlan_id: sel?.vlan_id ?? null,
|
||
role: "server",
|
||
kind: "host",
|
||
};
|
||
try {
|
||
const res = await api("POST", "/api/network/endpoints", body);
|
||
if (res.vlans) state.vlans = res.vlans;
|
||
renderVlans();
|
||
} catch (err) {
|
||
alert("Add host failed: " + err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function bind() {
|
||
$("#btn-network")?.addEventListener("click", openNetwork);
|
||
$("#btn-network-close")?.addEventListener("click", closeNetwork);
|
||
$("#network-tabs")?.addEventListener("click", (e) => {
|
||
const chip = e.target.closest("[data-ntab]");
|
||
if (!chip) return;
|
||
showTab(chip.dataset.ntab);
|
||
});
|
||
$("#scrim")?.addEventListener("click", () => {
|
||
if ($("#scrim")?.dataset.mode === "network-drawer") closeNetwork();
|
||
});
|
||
window.addEventListener("resize", () => {
|
||
if (state.tab === "fabric") drawCables();
|
||
});
|
||
}
|
||
|
||
bind();
|
||
window.cockpitNetwork = { open: openNetwork, close: closeNetwork, refresh };
|
||
})();
|