const { useEffect, useMemo, useRef, useState, useCallback } = React;
const Trash2 = ({ size = 24, ...props }) => (
);
const Plus = ({ size = 24, ...props }) => (
);
const Download = ({ size = 24, ...props }) => (
);
const Upload = ({ size = 24, ...props }) => (
);
const FolderDown = ({ size = 24, ...props }) => (
);
const Save = ({ size = 24, ...props }) => (
);
const FolderOpen = ({ size = 24, ...props }) => (
);
const Info = ({ size = 24, ...props }) => (
);
const Copy = ({ size = 24, ...props }) => (
);
const Edit3 = ({ size = 24, ...props }) => (
);
const Layers = ({ size = 24, ...props }) => (
);
function uuid() {
try { return crypto.randomUUID(); } catch (_) {}
return Math.random().toString(16).slice(2) + "-" + Date.now().toString(16);
}
function clamp(n, a, b) {
return Math.max(a, Math.min(b, n));
}
function safeInt(v, fallback = 1) {
const n = parseInt(String(v ?? "").trim(), 10);
return Number.isFinite(n) ? n : fallback;
}
function normalizeColor(v, fallback = "#60a5fa") {
const s = String(v ?? "").trim();
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s) ? s : fallback;
}
function slugify(s) {
return String(s || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "rack";
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function normalizeSideToken(tok) {
const t = String(tok || "").trim().toLowerCase();
if (t === "r" || t === "rear" || t === "back" || t === "b") return "rear";
return "front";
}
function isInteractive(el) {
return !!(el && el.closest("button, a, input, label, select, textarea"));
}
class PortGroup {
constructor(type, count, side = "front") {
this.type = type;
this.count = count;
this.side = side;
}
}
class Item {
constructor(name, u, color, notes = "", ports = [], isTemplate = true) {
this.id = uuid();
this.name = name;
this.u = u;
this.color = color;
this.notes = notes;
this.ports = ports;
this.isTemplate = isTemplate;
}
}
function parsePorts(input) {
if (!input) return [];
const raw = String(input).replace(/\r/g, "");
return raw
.split(/;|\n/)
.map(seg => String(seg).trim())
.filter(Boolean)
.map(seg => {
let side = "front";
let rest = seg;
const sideMatch = seg.match(/^(front|rear|f|r)\s*[-\s]\s*(.+)$/i);
if (sideMatch) {
side = normalizeSideToken(sideMatch[1]);
rest = sideMatch[2];
}
const parts = String(rest).split(":");
const rawType = (parts[0] || "Port").trim();
const rawCount = (parts[1] || "").trim();
const countRaw = rawCount ? Number(rawCount) : 1;
const count = Number.isFinite(countRaw) && countRaw > 0 ? Math.floor(countRaw) : 1;
return new PortGroup(rawType || "Port", count, side);
});
}
function normalizePortGroups(pg) {
if (!Array.isArray(pg)) return [];
return pg
.filter(Boolean)
.map(g => ({
type: String(g.type ?? "Port"),
count: Math.max(0, safeInt(g.count ?? 0, 0)),
side: String(g.side ?? "front").toLowerCase().startsWith("r") ? "rear" : "front",
}))
.filter(g => g.count > 0);
}
function normalizeItem(x, isTemplate) {
const ports = Array.isArray(x?.ports) ? normalizePortGroups(x.ports) : parsePorts(x?.Ports ?? x?.ports ?? "");
return {
id: x?.id || uuid(),
name: String(x?.name ?? x?.Name ?? "Device"),
u: Math.max(1, safeInt(x?.u ?? x?.U ?? 1, 1)),
color: normalizeColor(x?.color ?? x?.Color ?? "#60a5fa", "#60a5fa"),
notes: String(x?.notes ?? x?.Notes ?? ""),
ports,
isTemplate: !!isTemplate,
};
}
function normalizeCable(x) {
return {
id: x?.id || uuid(),
fromPortId: String(x?.fromPortId ?? x?.FromPortId ?? "").trim(),
toPortId: String(x?.toPortId ?? x?.ToPortId ?? "").trim(),
label: String(x?.label ?? x?.Label ?? ""),
type: String(x?.type ?? x?.Type ?? "Patch"),
color: normalizeColor(x?.color ?? x?.Color ?? "#38bdf8", "#38bdf8"),
notes: String(x?.notes ?? x?.Notes ?? ""),
};
}
function buildPlacedList(items, placements) {
return Object.entries(placements || {})
.map(([itemId, startU]) => {
const item = (items || []).find(i => i.id === itemId);
if (!item) return null;
const endU = startU + item.u - 1;
return { item, itemId, startU, endU };
})
.filter(Boolean)
.sort((a, b) => b.startU - a.startU);
}
function portTypeKey(type) {
return slugify(type || "port") || "port";
}
function makePortId(itemId, side, type, index) {
return `${itemId}::${side === "rear" ? "rear" : "front"}::${portTypeKey(type)}::${safeInt(index, 1)}`;
}
function parsePortId(portId) {
const [itemId = "", sideRaw = "front", typeKey = "port", indexRaw = "1"] = String(portId || "").split("::");
return {
itemId,
side: sideRaw === "rear" ? "rear" : "front",
typeKey,
index: Math.max(1, safeInt(indexRaw, 1)),
};
}
function buildPortInstances(item) {
const ports = [];
const groups = item?.ports || [];
groups.forEach((group) => {
const side = group.side === "rear" ? "rear" : "front";
const type = String(group.type || "Port");
const count = Math.max(0, safeInt(group.count ?? 0, 0));
for (let index = 1; index <= count; index += 1) {
ports.push({
id: makePortId(item.id, side, type, index),
itemId: item.id,
side,
type,
index,
sideLabel: side === "rear" ? "Rear" : "Front",
sideShort: side === "rear" ? "R" : "F",
label: `${side === "rear" ? "Rear" : "Front"} ${type} ${String(index).padStart(2, "0")}`,
shortLabel: `${type} ${String(index).padStart(2, "0")}`,
});
}
});
return ports;
}
function sanitizeCables(cables, placedItems) {
const validPorts = new Set((placedItems || []).flatMap(item => buildPortInstances(item).map(port => port.id)));
const usedPorts = new Set();
const next = [];
for (const rawCable of cables || []) {
const cable = normalizeCable(rawCable);
if (!cable.fromPortId || !cable.toPortId) continue;
if (cable.fromPortId === cable.toPortId) continue;
if (!validPorts.has(cable.fromPortId) || !validPorts.has(cable.toPortId)) continue;
if (usedPorts.has(cable.fromPortId) || usedPorts.has(cable.toPortId)) continue;
usedPorts.add(cable.fromPortId);
usedPorts.add(cable.toPortId);
next.push(cable);
}
return next;
}
function normalizeRack(r) {
const id = r?.id || uuid();
const name = String(r?.name ?? "Rack");
const rackU = clamp(safeInt(r?.rackU ?? 42, 42), 1, 60);
const placedItemsRaw = Array.isArray(r?.placedItems) ? r.placedItems : [];
const placedItems = placedItemsRaw.map(x => normalizeItem(x, false));
const itemById = new Map(placedItems.map(i => [i.id, i]));
const placementsRaw = r?.placements && typeof r.placements === "object" ? r.placements : {};
const cleanedPlacements = {};
for (const [pid, start] of Object.entries(placementsRaw)) {
const item = itemById.get(pid);
if (!item) continue;
const maxStart = Math.max(1, rackU - item.u + 1);
cleanedPlacements[pid] = clamp(safeInt(start, 1), 1, maxStart);
}
const cablesRaw = Array.isArray(r?.cables) ? r.cables : [];
const cables = sanitizeCables(cablesRaw, placedItems);
return {
id,
name,
rackU,
placedItems,
placements: cleanedPlacements,
cables,
};
}
function portsSummary(item) {
const groups = item?.ports || [];
if (!groups.length) return "";
const bySide = { front: [], rear: [] };
for (const group of groups) {
bySide[group.side === "rear" ? "rear" : "front"].push(group);
}
const fmt = arr => arr.map(g => `${g.type} x ${g.count}`).join(", ");
const front = bySide.front.length ? `F: ${fmt(bySide.front)}` : "";
const rear = bySide.rear.length ? `R: ${fmt(bySide.rear)}` : "";
return [front, rear].filter(Boolean).join(" | ");
}
function isFreeInPlacedList(startU, itemU, placedList, rackU, ignoreId = null) {
const endU = startU + itemU - 1;
if (startU < 1 || endU > rackU) return false;
for (const placed of placedList || []) {
if (ignoreId && placed.itemId === ignoreId) continue;
const overlap = !(endU < placed.startU || startU > placed.endU);
if (overlap) return false;
}
return true;
}
function resolveDropPreview(e, rackEl, rackU, itemU, pointerOffsetU, placedSnapshot, ignoreId = null) {
if (!rackEl) {
return { hoverU: null, previewStartU: null, valid: false };
}
const rect = rackEl.getBoundingClientRect();
if (!rect.height) {
return { hoverU: null, previewStartU: null, valid: false };
}
const localY = clamp(e.clientY - rect.top, 0, rect.height);
const hoverU = clamp(Math.ceil((1 - (localY / rect.height)) * rackU), 1, rackU);
const maxStart = Math.max(1, rackU - itemU + 1);
let previewStartU = clamp(hoverU - pointerOffsetU, 1, maxStart);
const hoveredPlaced = (placedSnapshot || []).find(record => record.itemId !== ignoreId && hoverU >= record.startU && hoverU <= record.endU);
if (hoveredPlaced) {
const itemTopPx = rect.height - (((hoveredPlaced.startU - 1) + hoveredPlaced.item.u) / rackU) * rect.height;
const itemBottomPx = rect.height - ((hoveredPlaced.startU - 1) / rackU) * rect.height;
const midPx = (itemTopPx + itemBottomPx) / 2;
const preferred = localY < midPx
? hoveredPlaced.endU + 1
: hoveredPlaced.startU - itemU;
previewStartU = clamp(preferred, 1, maxStart);
}
const valid = isFreeInPlacedList(previewStartU, itemU, placedSnapshot, rackU, ignoreId);
return { hoverU, previewStartU, valid };
}
function getPortAnchorPoint(port, placedRecord, rackU) {
const sidePorts = buildPortInstances(placedRecord.item).filter(p => p.side === port.side);
const sideCount = Math.max(1, sidePorts.length);
const slotIndex = Math.max(0, sidePorts.findIndex(p => p.id === port.id));
const height = (placedRecord.item.u / rackU) * 100;
const top = 100 - (((placedRecord.startU - 1) + placedRecord.item.u) / rackU) * 100;
const y = top + ((slotIndex + 0.5) / sideCount) * height;
const x = port.side === "rear" ? 92 : 8;
return { x, y };
}
function buildVisibleSideMarkers(sidePorts, maxVisible) {
if (!Array.isArray(sidePorts) || !sidePorts.length || maxVisible <= 0) return [];
const visibleCount = Math.min(sidePorts.length, maxVisible);
if (visibleCount === 1) {
const centerPort = sidePorts[Math.floor((sidePorts.length - 1) / 2)];
return [{
...centerPort,
displayIndex: 0,
displayCount: 1,
}];
}
const step = (sidePorts.length - 1) / (visibleCount - 1);
return Array.from({ length: visibleCount }, (_, displayIndex) => {
const sourceIndex = Math.round(displayIndex * step);
return {
...sidePorts[sourceIndex],
displayIndex,
displayCount: visibleCount,
};
});
}
function createWorksheet(headers, rows) {
const table = [headers, ...(rows || []).map(row => headers.map(header => row[header] ?? ""))];
return XLSX.utils.aoa_to_sheet(table);
}
function App() {
return ;
}
function RackPlanner() {
const rackRef = useRef(null);
const toastTimer = useRef(null);
const pointerCaptureRef = useRef(null);
const dragListenersRef = useRef({ move: null, up: null, cancel: null });
const [invFilter, setInvFilter] = useState("");
const [newName, setNewName] = useState("");
const [newU, setNewU] = useState(1);
const [newColor, setNewColor] = useState("#60a5fa");
const [newPorts, setNewPorts] = useState("");
const [newNotes, setNewNotes] = useState("");
const [importing, setImporting] = useState(false);
const [toast, setToast] = useState(null);
const initialRackId = useMemo(() => uuid(), []);
const [templates, setTemplates] = useState(() => ([
new Item("1U Server", 1, "#60a5fa", "General compute server", parsePorts("Front-RJ45:2;Rear-AC:2"), true),
new Item("2U UPS", 2, "#10b981", "Battery backup", parsePorts("Rear-AC:2"), true),
new Item("Switch 24p", 1, "#f59e0b", "24 port access switch", parsePorts("Front-RJ45:24;Front-SFP:4;Rear-AC:1"), true),
]));
const [racks, setRacks] = useState(() => ([
{ id: initialRackId, name: "Rack 1", rackU: 42, placedItems: [], placements: {}, cables: [] }
]));
const [activeRackId, setActiveRackId] = useState(initialRackId);
const [selectedItemId, setSelectedItemId] = useState(null);
const [portDraft, setPortDraft] = useState(null);
const [portTargetId, setPortTargetId] = useState("");
const [cableLabel, setCableLabel] = useState("");
const [cableType, setCableType] = useState("Patch");
const [cableColor, setCableColor] = useState("#38bdf8");
const [cableNotes, setCableNotes] = useState("");
const [dragSession, setDragSession] = useState(null);
const showToast = useCallback((msg, kind = "info") => {
setToast({ msg, kind });
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2400);
}, []);
const detachDragListeners = useCallback(() => {
const listeners = dragListenersRef.current;
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
dragListenersRef.current = { move: null, up: null, cancel: null };
}, []);
useEffect(() => {
return () => {
detachDragListeners();
clearTimeout(toastTimer.current);
};
}, [detachDragListeners]);
const activeRack = useMemo(() => racks.find(r => r.id === activeRackId) || racks[0] || null, [racks, activeRackId]);
const rackU = activeRack?.rackU ?? 42;
const placedItems = activeRack?.placedItems ?? [];
const placements = activeRack?.placements ?? {};
const cables = activeRack?.cables ?? [];
const placedList = useMemo(() => buildPlacedList(placedItems, placements), [placedItems, placements]);
useEffect(() => {
if (!racks.length) return;
if (!racks.some(r => r.id === activeRackId)) {
setActiveRackId(racks[0].id);
}
}, [racks, activeRackId]);
useEffect(() => {
if (!placedList.length) {
setSelectedItemId(null);
return;
}
if (!placedList.some(record => record.itemId === selectedItemId)) {
setSelectedItemId(placedList[0].itemId);
}
}, [placedList, selectedItemId, activeRackId]);
const activePorts = useMemo(() => {
return placedList.flatMap(record =>
buildPortInstances(record.item).map(port => ({
...port,
rackId: activeRack?.id ?? "",
rackName: activeRack?.name ?? "Rack",
deviceId: record.itemId,
deviceName: record.item.name,
startU: record.startU,
endU: record.endU,
}))
);
}, [placedList, activeRack]);
const portsById = useMemo(() => {
const map = new Map();
activePorts.forEach(port => map.set(port.id, port));
return map;
}, [activePorts]);
const cableByPortId = useMemo(() => {
const map = new Map();
(cables || []).forEach(cable => {
map.set(cable.fromPortId, cable);
map.set(cable.toPortId, cable);
});
return map;
}, [cables]);
useEffect(() => {
if (portDraft && !portsById.has(portDraft)) {
setPortDraft(null);
}
}, [portDraft, portsById]);
useEffect(() => {
if (portTargetId && !portsById.has(portTargetId)) {
setPortTargetId("");
}
}, [portTargetId, portsById]);
const selectedPlaced = useMemo(
() => placedList.find(record => record.itemId === selectedItemId) || null,
[placedList, selectedItemId]
);
const selectedPorts = useMemo(
() => activePorts.filter(port => port.deviceId === selectedItemId),
[activePorts, selectedItemId]
);
const selectedFrontPorts = useMemo(
() => selectedPorts.filter(port => port.side === "front"),
[selectedPorts]
);
const selectedRearPorts = useMemo(
() => selectedPorts.filter(port => port.side === "rear"),
[selectedPorts]
);
const openTargetPorts = useMemo(() => {
return activePorts.filter(port => port.id !== portDraft && !cableByPortId.has(port.id));
}, [activePorts, portDraft, cableByPortId]);
const itemConnectionStats = useMemo(() => {
const stats = new Map();
for (const record of placedList) {
const ports = buildPortInstances(record.item);
const connected = ports.filter(port => cableByPortId.has(port.id)).length;
stats.set(record.itemId, {
totalPorts: ports.length,
connectedPorts: connected,
});
}
return stats;
}, [placedList, cableByPortId]);
const enrichedCables = useMemo(() => {
return (cables || [])
.map(cable => {
const fromPort = portsById.get(cable.fromPortId);
const toPort = portsById.get(cable.toPortId);
if (!fromPort || !toPort) return null;
const fromRecord = placedList.find(record => record.itemId === fromPort.deviceId);
const toRecord = placedList.find(record => record.itemId === toPort.deviceId);
if (!fromRecord || !toRecord) return null;
const fromPoint = getPortAnchorPoint(fromPort, fromRecord, rackU);
const toPoint = getPortAnchorPoint(toPort, toRecord, rackU);
const dx = Math.abs(toPoint.x - fromPoint.x);
const curve = Math.max(12, dx * 0.55);
const c1x = fromPoint.x < toPoint.x ? fromPoint.x + curve : fromPoint.x - curve;
const c2x = fromPoint.x < toPoint.x ? toPoint.x - curve : toPoint.x + curve;
return {
...cable,
fromPort,
toPort,
fromRecord,
toRecord,
path: `M ${fromPoint.x} ${fromPoint.y} C ${c1x} ${fromPoint.y}, ${c2x} ${toPoint.y}, ${toPoint.x} ${toPoint.y}`,
fromPoint,
toPoint,
};
})
.filter(Boolean);
}, [cables, portsById, placedList, rackU]);
const activePortDraftInfo = portDraft ? portsById.get(portDraft) || null : null;
const templateView = useMemo(() => {
const q = invFilter.trim().toLowerCase();
return templates
.filter(item => {
if (!q) return true;
return [item.name, item.notes, portsSummary(item)].join(" ").toLowerCase().includes(q);
})
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}, [templates, invFilter]);
const updateRackById = useCallback((rackId, updater) => {
setRacks(prev => prev.map(rack => rack.id === rackId ? updater(rack) : rack));
}, []);
const updateActiveRack = useCallback((updater) => {
if (!activeRackId) return;
updateRackById(activeRackId, updater);
}, [activeRackId, updateRackById]);
const clearCableDraft = useCallback(() => {
setPortDraft(null);
setPortTargetId("");
}, []);
function addTemplate({ name, u, color, notes = "", ports = [] }) {
const template = new Item(name, u, color, notes, ports, true);
setTemplates(prev => [template, ...prev]);
showToast("Added template.", "success");
}
const removeTemplate = useCallback((templateId) => {
setTemplates(prev => prev.filter(item => item.id !== templateId));
showToast("Removed template.", "info");
}, [showToast]);
function addRack() {
const id = uuid();
const name = `Rack ${racks.length + 1}`;
const next = { id, name, rackU, placedItems: [], placements: {}, cables: [] };
setRacks(prev => [...prev, next]);
setActiveRackId(id);
clearCableDraft();
showToast("Added rack.", "success");
}
const renameActiveRack = useCallback(() => {
if (!activeRack) return;
const nextName = window.prompt("Rename rack:", activeRack.name);
if (nextName == null) return;
const name = String(nextName).trim();
if (!name) return;
updateActiveRack(rack => ({ ...rack, name }));
showToast("Renamed rack.", "success");
}, [activeRack, updateActiveRack, showToast]);
const duplicateActiveRack = useCallback(() => {
if (!activeRack) return;
const id = uuid();
const name = `${activeRack.name} (Copy)`;
const idMap = new Map();
const placedClone = (activeRack.placedItems || []).map(item => {
const copy = {
id: uuid(),
name: String(item.name ?? "Device"),
u: Math.max(1, safeInt(item.u ?? 1, 1)),
color: normalizeColor(item.color ?? "#60a5fa", "#60a5fa"),
notes: String(item.notes ?? ""),
ports: normalizePortGroups(item.ports),
isTemplate: false,
};
idMap.set(item.id, copy.id);
return copy;
});
const placementsClone = {};
for (const [oldId, startU] of Object.entries(activeRack.placements || {})) {
const nextId = idMap.get(oldId);
if (nextId) placementsClone[nextId] = safeInt(startU, 1);
}
const cablesClone = (activeRack.cables || [])
.map(cable => {
const from = parsePortId(cable.fromPortId);
const to = parsePortId(cable.toPortId);
const nextFromItemId = idMap.get(from.itemId);
const nextToItemId = idMap.get(to.itemId);
if (!nextFromItemId || !nextToItemId) return null;
return {
...normalizeCable(cable),
id: uuid(),
fromPortId: makePortId(nextFromItemId, from.side, from.typeKey, from.index),
toPortId: makePortId(nextToItemId, to.side, to.typeKey, to.index),
};
})
.filter(Boolean);
const next = {
id,
name,
rackU: clamp(activeRack.rackU ?? 42, 1, 60),
placedItems: placedClone,
placements: placementsClone,
cables: sanitizeCables(cablesClone, placedClone),
};
setRacks(prev => [...prev, next]);
setActiveRackId(id);
clearCableDraft();
showToast("Duplicated rack.", "success");
}, [activeRack, showToast, clearCableDraft]);
const deleteActiveRack = useCallback(() => {
if (racks.length <= 1) {
showToast("You must keep at least one rack.", "warn");
return;
}
if (!activeRack) return;
const ok = window.confirm(`Delete "${activeRack.name}"? This cannot be undone.`);
if (!ok) return;
const idx = racks.findIndex(rack => rack.id === activeRackId);
const nextRacks = racks.filter(rack => rack.id !== activeRackId);
const nextActive = nextRacks[Math.max(0, idx - 1)] || nextRacks[0];
setRacks(nextRacks);
setActiveRackId(nextActive.id);
clearCableDraft();
showToast("Deleted rack.", "info");
}, [racks, activeRack, activeRackId, showToast, clearCableDraft]);
function placeFromTemplate(templateId, startU) {
const template = templates.find(item => item.id === templateId);
if (!template) return null;
const instance = new Item(
template.name,
template.u,
template.color,
template.notes,
normalizePortGroups(template.ports),
false
);
updateActiveRack(rack => ({
...rack,
placedItems: [instance, ...(rack.placedItems || [])],
placements: { ...(rack.placements || {}), [instance.id]: startU },
cables: rack.cables || [],
}));
return instance.id;
}
const movePlaced = useCallback((itemId, startU) => {
updateActiveRack(rack => ({
...rack,
placements: { ...(rack.placements || {}), [itemId]: startU },
}));
}, [updateActiveRack]);
const removePlaced = useCallback((itemId) => {
clearCableDraft();
updateActiveRack(rack => {
const nextPlaced = (rack.placedItems || []).filter(item => item.id !== itemId);
const nextPlacements = { ...(rack.placements || {}) };
delete nextPlacements[itemId];
const nextCables = (rack.cables || []).filter(cable => {
const fromItem = parsePortId(cable.fromPortId).itemId;
const toItem = parsePortId(cable.toPortId).itemId;
return fromItem !== itemId && toItem !== itemId;
});
return {
...rack,
placedItems: nextPlaced,
placements: nextPlacements,
cables: nextCables,
};
});
}, [updateActiveRack, clearCableDraft]);
const clearRack = useCallback(() => {
clearCableDraft();
setSelectedItemId(null);
updateActiveRack(rack => ({ ...rack, placedItems: [], placements: {}, cables: [] }));
showToast("Cleared active rack.", "info");
}, [updateActiveRack, showToast, clearCableDraft]);
const isFree = useCallback((startU, itemU, ignoreId = null) => {
return isFreeInPlacedList(startU, itemU, placedList, rackU, ignoreId);
}, [placedList, rackU]);
const removeCable = useCallback((cableId) => {
updateActiveRack(rack => ({
...rack,
cables: (rack.cables || []).filter(cable => cable.id !== cableId),
}));
showToast("Removed cable.", "info");
}, [updateActiveRack, showToast]);
const clearRackCables = useCallback(() => {
clearCableDraft();
updateActiveRack(rack => ({ ...rack, cables: [] }));
showToast("Cleared rack cables.", "info");
}, [updateActiveRack, showToast, clearCableDraft]);
const connectPorts = useCallback((fromPortId, toPortId) => {
if (!fromPortId || !toPortId) {
showToast("Choose both cable endpoints.", "warn");
return;
}
if (fromPortId === toPortId) {
showToast("A cable needs two different ports.", "warn");
return;
}
if (!portsById.has(fromPortId) || !portsById.has(toPortId)) {
showToast("One or more ports are no longer available.", "warn");
return;
}
if (cableByPortId.has(fromPortId) || cableByPortId.has(toPortId)) {
showToast("Each port can only hold one cable.", "warn");
return;
}
const cable = {
id: uuid(),
fromPortId,
toPortId,
label: cableLabel.trim() || `Cable ${String(cables.length + 1).padStart(2, "0")}`,
type: cableType.trim() || "Patch",
color: normalizeColor(cableColor, "#38bdf8"),
notes: cableNotes.trim(),
};
updateActiveRack(rack => ({
...rack,
cables: [...(rack.cables || []), cable],
}));
setCableLabel("");
setCableNotes("");
setPortDraft(null);
setPortTargetId("");
showToast("Connected ports.", "success");
}, [portsById, cableByPortId, cableLabel, cableType, cableColor, cableNotes, updateActiveRack, showToast, cables.length]);
function connectDraftToSelectedTarget() {
if (!portDraft || !portTargetId) {
showToast("Pick a target port to finish the cable.", "warn");
return;
}
connectPorts(portDraft, portTargetId);
}
function beginPortDraft(portId) {
if (!portsById.has(portId)) return;
if (cableByPortId.has(portId)) {
showToast("That port is already connected.", "warn");
return;
}
if (portDraft === portId) {
setPortDraft(null);
setPortTargetId("");
return;
}
setPortDraft(portId);
setPortTargetId("");
}
function handlePortAction(portId) {
if (!portId) return;
if (cableByPortId.has(portId)) {
showToast("Disconnect the existing cable before reusing that port.", "warn");
return;
}
if (!portDraft) {
beginPortDraft(portId);
return;
}
if (portDraft === portId) {
setPortDraft(null);
setPortTargetId("");
return;
}
connectPorts(portDraft, portId);
}
function startTemplateDrag(templateId, e) {
const template = templates.find(item => item.id === templateId);
if (!template) return;
const rackEl = rackRef.current;
const placedSnapshot = [...placedList];
const rackUSnapshot = rackU;
let session = {
kind: "template",
templateId,
itemId: templateId,
itemU: template.u,
sourceStartU: null,
hoverU: null,
previewStartU: null,
valid: false,
pointerOffsetU: 0,
};
session = { ...session, ...resolveDropPreview(e, rackEl, rackUSnapshot, template.u, 0, placedSnapshot, null) };
setDragSession(session);
try {
if (e?.currentTarget?.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
pointerCaptureRef.current = e.currentTarget;
}
} catch (_) {}
detachDragListeners();
const onMove = (evt) => {
session = { ...session, ...resolveDropPreview(evt, rackEl, rackUSnapshot, template.u, 0, placedSnapshot, null) };
setDragSession(session);
};
const finalize = (evt, cancelled = false) => {
try {
if (pointerCaptureRef.current && typeof pointerCaptureRef.current.releasePointerCapture === "function" && evt?.pointerId != null) {
pointerCaptureRef.current.releasePointerCapture(evt.pointerId);
}
} catch (_) {}
pointerCaptureRef.current = null;
detachDragListeners();
const finalSession = session;
if (!cancelled && finalSession.previewStartU != null && finalSession.valid) {
const nextId = placeFromTemplate(templateId, finalSession.previewStartU);
if (nextId) setSelectedItemId(nextId);
showToast("Placed item.", "success");
} else if (!cancelled) {
showToast("That rack space is occupied.", "warn");
}
setDragSession(null);
};
dragListenersRef.current = {
move: onMove,
up: (evt) => finalize(evt, false),
cancel: (evt) => finalize(evt, true),
};
window.addEventListener("pointermove", dragListenersRef.current.move, { passive: true });
window.addEventListener("pointerup", dragListenersRef.current.up, { passive: true });
window.addEventListener("pointercancel", dragListenersRef.current.cancel, { passive: true });
}
function startPlacedDrag(itemId, e, itemU, startU) {
const rackEl = rackRef.current;
const placedSnapshot = [...placedList];
const rackUSnapshot = rackU;
const initialHover = resolveDropPreview(e, rackEl, rackUSnapshot, itemU, 0, placedSnapshot, itemId).hoverU;
const pointerOffsetU = clamp((initialHover ?? startU) - startU, 0, Math.max(0, itemU - 1));
let session = {
kind: "placed",
templateId: null,
itemId,
itemU,
sourceStartU: startU,
hoverU: null,
previewStartU: startU,
valid: true,
pointerOffsetU,
};
session = { ...session, ...resolveDropPreview(e, rackEl, rackUSnapshot, itemU, pointerOffsetU, placedSnapshot, itemId) };
setSelectedItemId(itemId);
setDragSession(session);
try {
if (e?.currentTarget?.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
pointerCaptureRef.current = e.currentTarget;
}
} catch (_) {}
detachDragListeners();
const onMove = (evt) => {
session = { ...session, ...resolveDropPreview(evt, rackEl, rackUSnapshot, itemU, pointerOffsetU, placedSnapshot, itemId) };
setDragSession(session);
};
const finalize = (evt, cancelled = false) => {
try {
if (pointerCaptureRef.current && typeof pointerCaptureRef.current.releasePointerCapture === "function" && evt?.pointerId != null) {
pointerCaptureRef.current.releasePointerCapture(evt.pointerId);
}
} catch (_) {}
pointerCaptureRef.current = null;
detachDragListeners();
const finalSession = session;
if (!cancelled && finalSession.previewStartU != null && finalSession.valid) {
if (finalSession.previewStartU !== startU) {
movePlaced(itemId, finalSession.previewStartU);
showToast("Moved item.", "success");
}
} else if (!cancelled) {
showToast("That rack space is occupied.", "warn");
}
setDragSession(null);
};
dragListenersRef.current = {
move: onMove,
up: (evt) => finalize(evt, false),
cancel: (evt) => finalize(evt, true),
};
window.addEventListener("pointermove", dragListenersRef.current.move, { passive: true });
window.addEventListener("pointerup", dragListenersRef.current.up, { passive: true });
window.addEventListener("pointercancel", dragListenersRef.current.cancel, { passive: true });
}
function exportTemplate() {
const header = ["Name", "U", "Color", "Notes", "Ports"];
const example = [
["Switch 24p", "1", "#f59e0b", "Access switch", "Front-RJ45:24;Front-SFP:4;Rear-AC:1"],
["2U UPS", "2", "#10b981", "UPS", "Rear-AC:2"],
["Patch Panel", "1", "#a78bfa", "Copper patching", "Front-RJ45:24"],
];
const rows = [header, ...example];
const csv = rows.map(row => row.map(value => {
const s = String(value ?? "");
if (s.includes(",") || s.includes('"') || s.includes("\n")) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}).join(",")).join("\n");
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8" }), "engever-inventory-template.csv");
showToast("Downloaded inventory template.", "success");
}
async function onImportFile(file) {
if (!file) {
showToast("No file selected.", "warn");
return;
}
setImporting(true);
try {
const isCSV = file.name.toLowerCase().endsWith(".csv");
let rows = [];
if (isCSV) {
const csvText = await file.text();
if (!csvText) {
showToast("CSV file is empty.", "warn");
return;
}
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true });
if (parsed.errors?.length) console.warn("CSV parsing errors:", parsed.errors);
rows = parsed.data || [];
} else {
const buf = await file.arrayBuffer();
if (!buf || buf.byteLength === 0) {
showToast("Spreadsheet file is empty.", "warn");
return;
}
const workbook = XLSX.read(buf, { type: "array" });
if (!workbook || !workbook.SheetNames.length) {
showToast("No sheets found in spreadsheet.", "warn");
return;
}
const sheet = workbook.Sheets[workbook.SheetNames[0]];
rows = XLSX.utils.sheet_to_json(sheet, { defval: "" });
}
const parsedItems = rows
.filter(row => (row.Name || row.name || row.U || row.u))
.map((row, index) => {
const name = String(row.Name ?? row.name ?? `Device ${index + 1}`).trim() || `Device ${index + 1}`;
const u = Math.max(1, safeInt(row.U ?? row.u ?? 1, 1));
const color = normalizeColor(row.Color ?? row.color ?? "#60a5fa", "#60a5fa");
const notes = String(row.Notes ?? row.notes ?? "").trim();
const ports = parsePorts(row.Ports ?? row.ports ?? "");
return new Item(name, u, color, notes, ports, true);
});
if (!parsedItems.length) {
showToast("No valid items found. Check your template columns.", "warn");
return;
}
setTemplates(prev => [...parsedItems, ...prev]);
showToast(`Imported ${parsedItems.length} template(s).`, "success");
} catch (error) {
console.error("Import error:", error);
showToast(`Import failed: ${error.message || "Unknown error"}`, "warn");
} finally {
setImporting(false);
}
}
async function exportPNG() {
const node = rackRef.current;
if (!node) {
showToast("Rack element not found.", "warn");
return;
}
if (!activeRack) {
showToast("No active rack.", "warn");
return;
}
try {
const dataUrl = await htmlToImage.toPng(node, { pixelRatio: 2, backgroundColor: "#020617" });
const a = document.createElement("a");
a.href = dataUrl;
a.download = `engever-${slugify(activeRack.name)}-${rackU}U-connected.png`;
a.click();
showToast("Exported PNG.", "success");
} catch (error) {
console.error("PNG export error:", error);
showToast(`PNG export failed: ${error.message || "Unknown error"}`, "warn");
}
}
function buildBOMRows() {
const grouped = new Map();
for (const record of placedList) {
const stats = itemConnectionStats.get(record.itemId) || { totalPorts: 0, connectedPorts: 0 };
const key = [
record.item.name,
record.item.u,
record.item.notes || "",
portsSummary(record.item),
].join("||");
if (!grouped.has(key)) {
grouped.set(key, {
Device: record.item.name,
"Rack Units": record.item.u,
"Ports Summary": portsSummary(record.item),
Notes: record.item.notes || "",
Quantity: 0,
"Ports Per Device": stats.totalPorts,
});
}
grouped.get(key).Quantity += 1;
}
return Array.from(grouped.values()).sort((a, b) => String(a.Device).localeCompare(String(b.Device)));
}
function buildPlacementRows() {
return placedList.map(record => {
const stats = itemConnectionStats.get(record.itemId) || { totalPorts: 0, connectedPorts: 0 };
return {
Rack: activeRack?.name ?? "Rack",
DeviceId: record.itemId,
Device: record.item.name,
U: record.item.u,
StartU: record.startU,
EndU: record.endU,
Ports: stats.totalPorts,
ConnectedPorts: stats.connectedPorts,
Notes: record.item.notes || "",
};
}).sort((a, b) => b.StartU - a.StartU);
}
function buildPortMapRows() {
return activePorts.map(port => {
const cable = cableByPortId.get(port.id);
const oppositePortId = cable ? (cable.fromPortId === port.id ? cable.toPortId : cable.fromPortId) : "";
const oppositePort = oppositePortId ? portsById.get(oppositePortId) : null;
return {
Rack: port.rackName,
DeviceId: port.deviceId,
Device: port.deviceName,
StartU: port.startU,
EndU: port.endU,
Side: port.sideLabel,
PortType: port.type,
PortIndex: port.index,
PortLabel: port.label,
Status: cable ? "Connected" : "Open",
CableId: cable?.id || "",
CableLabel: cable?.label || "",
CableType: cable?.type || "",
CableColor: cable?.color || "",
ConnectedToDevice: oppositePort?.deviceName || "",
ConnectedToSide: oppositePort?.sideLabel || "",
ConnectedToPort: oppositePort?.label || "",
CableNotes: cable?.notes || "",
};
}).sort((a, b) => {
if (a.StartU !== b.StartU) return b.StartU - a.StartU;
if (a.Device !== b.Device) return String(a.Device).localeCompare(String(b.Device));
if (a.Side !== b.Side) return String(a.Side).localeCompare(String(b.Side));
return a.PortIndex - b.PortIndex;
});
}
function buildConnectionRows() {
return enrichedCables.map(cable => ({
Rack: activeRack?.name ?? "Rack",
CableId: cable.id,
CableLabel: cable.label || "",
CableType: cable.type || "",
CableColor: cable.color || "",
FromDevice: cable.fromPort.deviceName,
FromStartU: cable.fromPort.startU,
FromSide: cable.fromPort.sideLabel,
FromPort: cable.fromPort.label,
ToDevice: cable.toPort.deviceName,
ToStartU: cable.toPort.startU,
ToSide: cable.toPort.sideLabel,
ToPort: cable.toPort.label,
Notes: cable.notes || "",
})).sort((a, b) => String(a.CableLabel).localeCompare(String(b.CableLabel)));
}
function exportXLSX() {
if (!activeRack) {
showToast("No active rack to export.", "warn");
return;
}
try {
const bomRows = buildBOMRows();
const placementRows = buildPlacementRows();
const portMapRows = buildPortMapRows();
const connectionRows = buildConnectionRows();
if (!placementRows.length) {
showToast("No items to export.", "warn");
return;
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, createWorksheet(
["Device", "Rack Units", "Ports Summary", "Notes", "Quantity", "Ports Per Device"],
bomRows
), "BOM");
XLSX.utils.book_append_sheet(workbook, createWorksheet(
["Rack", "DeviceId", "Device", "U", "StartU", "EndU", "Ports", "ConnectedPorts", "Notes"],
placementRows
), "Placements");
XLSX.utils.book_append_sheet(workbook, createWorksheet(
["Rack", "DeviceId", "Device", "StartU", "EndU", "Side", "PortType", "PortIndex", "PortLabel", "Status", "CableId", "CableLabel", "CableType", "CableColor", "ConnectedToDevice", "ConnectedToSide", "ConnectedToPort", "CableNotes"],
portMapRows
), "Port Map");
XLSX.utils.book_append_sheet(workbook, createWorksheet(
["Rack", "CableId", "CableLabel", "CableType", "CableColor", "FromDevice", "FromStartU", "FromSide", "FromPort", "ToDevice", "ToStartU", "ToSide", "ToPort", "Notes"],
connectionRows
), "Connections");
XLSX.writeFile(workbook, `engever-${slugify(activeRack.name)}-${rackU}U-connected-export.xlsx`);
showToast("Exported XLSX.", "success");
} catch (error) {
console.error("XLSX export error:", error);
showToast(`XLSX export failed: ${error.message || "Unknown error"}`, "warn");
}
}
function saveJSON() {
try {
const payload = { version: 3, templates, racks, activeRackId };
downloadBlob(
new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }),
"engever-project-connected.json"
);
showToast("Saved project JSON.", "success");
} catch (error) {
console.error("Save error:", error);
showToast(`Save failed: ${error.message || "Unknown error"}`, "warn");
}
}
async function loadJSON(file) {
if (!file) {
showToast("No file selected.", "warn");
return;
}
try {
const text = await file.text();
if (!text) {
showToast("JSON file is empty.", "warn");
return;
}
const payload = JSON.parse(text);
if (!payload) {
showToast("Invalid JSON format.", "warn");
return;
}
if (payload && Array.isArray(payload.racks)) {
const nextTemplates = Array.isArray(payload.templates)
? payload.templates.map(item => normalizeItem(item, true))
: [];
const nextRacksRaw = payload.racks.map(rack => normalizeRack(rack)).filter(Boolean);
const safeRacks = nextRacksRaw.length
? nextRacksRaw
: [{ id: uuid(), name: "Rack 1", rackU: 42, placedItems: [], placements: {}, cables: [] }];
const nextActiveRackId = payload.activeRackId && safeRacks.some(rack => rack.id === payload.activeRackId)
? payload.activeRackId
: safeRacks[0].id;
setTemplates(nextTemplates.length ? nextTemplates : templates);
setRacks(safeRacks);
setActiveRackId(nextActiveRackId);
clearCableDraft();
showToast("Loaded project JSON.", "success");
return;
}
const nextRackU = typeof payload.rackU === "number" ? clamp(payload.rackU, 1, 60) : rackU;
const legacyItems = Array.isArray(payload.items) ? payload.items : [];
const normalized = legacyItems.filter(Boolean).map(item => normalizeItem(item, !!item.isTemplate));
const legacyTemplates = normalized.filter(item => item.isTemplate).map(item => ({ ...item, isTemplate: true }));
const legacyPlaced = normalized.filter(item => !item.isTemplate).map(item => ({ ...item, isTemplate: false }));
const placementsRaw = payload.placements && typeof payload.placements === "object" ? payload.placements : {};
const itemById = new Map(legacyPlaced.map(item => [item.id, item]));
const cleanedPlacements = {};
for (const [pid, start] of Object.entries(placementsRaw)) {
const item = itemById.get(pid);
if (!item) continue;
const maxStart = Math.max(1, nextRackU - item.u + 1);
cleanedPlacements[pid] = clamp(safeInt(start, 1), 1, maxStart);
}
const rackId = uuid();
setTemplates(legacyTemplates.length ? legacyTemplates : templates);
setRacks([{ id: rackId, name: "Rack 1", rackU: nextRackU, placedItems: legacyPlaced, placements: cleanedPlacements, cables: [] }]);
setActiveRackId(rackId);
clearCableDraft();
showToast("Loaded legacy layout JSON.", "success");
} catch (error) {
console.error("Load error:", error);
showToast(`Failed to load JSON: ${error.message || "Invalid file"}`, "warn");
}
}
function ItemBlock({ record, isGhost = false }) {
const { item, startU } = record;
const heightPercent = (item.u / rackU) * 100;
const bottomPercent = ((startU - 1) / rackU) * 100;
const stats = itemConnectionStats.get(item.id) || { totalPorts: 0, connectedPorts: 0 };
const isSelected = !isGhost && selectedItemId === item.id;
const isDraggingOrigin = dragSession?.kind === "placed" && dragSession?.itemId === item.id && !isGhost;
const validAtPosition = isGhost ? dragSession?.valid !== false : isFree(startU, item.u, item.id);
const ports = buildPortInstances(item);
const frontPorts = ports.filter(port => port.side === "front");
const rearPorts = ports.filter(port => port.side === "rear");
const itemHeightPx = Math.max(10, (740 / rackU) * item.u - 4);
const sizeMode = itemHeightPx < 22 ? "micro" : itemHeightPx < 36 ? "compact" : itemHeightPx < 60 ? "regular" : "tall";
const dotSizePx = sizeMode === "micro" ? 4 : sizeMode === "compact" ? 5 : 6;
const markerInsetPx = sizeMode === "micro" ? 3 : 5;
const markerBudget = clamp(
Math.floor((itemHeightPx - 4) / (dotSizePx + (sizeMode === "micro" ? 3 : 4))),
1,
isSelected ? 10 : 6
);
const frontMarkers = buildVisibleSideMarkers(frontPorts, markerBudget);
const rearMarkers = buildVisibleSideMarkers(rearPorts, markerBudget);
const nameFontPx = sizeMode === "micro" ? 8 : sizeMode === "compact" ? 9 : sizeMode === "regular" ? 11 : 12;
const detailFontPx = sizeMode === "compact" ? 8 : sizeMode === "regular" ? 9 : 10;
const badgeFontPx = sizeMode === "micro" ? 8 : 9;
const actionSizePx = sizeMode === "micro" ? 18 : 22;
const iconSizePx = sizeMode === "micro" ? 10 : 13;
const padY = sizeMode === "micro" ? 1 : sizeMode === "compact" ? 2 : 4;
const padLeft = frontPorts.length ? dotSizePx + markerInsetPx + 8 : 8;
const padRight = Math.max(rearPorts.length ? dotSizePx + markerInsetPx + 8 : 8, sizeMode === "micro" ? 52 : 74);
const canShowSummary = !isGhost && itemHeightPx >= 30;
const canShowStatus = !isGhost && stats.totalPorts > 0 && itemHeightPx >= 24;
const compactStatus = stats.totalPorts > 0 ? `${stats.connectedPorts}/${stats.totalPorts}` : "";
function renderSideMarkers(markers, side) {
return markers.map(marker => {
const cable = cableByPortId.get(marker.id);
const topPercent = ((marker.displayIndex + 0.5) / marker.displayCount) * 100;
return (
);
});
}
return (
{
if (isGhost) return;
if (isInteractive(e.target)) return;
e.preventDefault();
setSelectedItemId(item.id);
startPlacedDrag(item.id, e, item.u, startU);
}}
onClick={() => {
if (!isGhost) setSelectedItemId(item.id);
}}
>
{renderSideMarkers(frontMarkers, "front")}
{renderSideMarkers(rearMarkers, "rear")}
{sizeMode === "micro" ? (
{item.u}U
{!!compactStatus && (
{compactStatus}
)}
{!isGhost && (
)}
) : (
{item.name}
{canShowSummary && (
{portsSummary(item) || "No defined ports"}
)}
{item.u}U
{stats.totalPorts > 0 && (
{stats.connectedPorts}/{stats.totalPorts}
)}
{!isGhost && (
)}
{canShowStatus && (
{stats.connectedPorts ? `${stats.connectedPorts} connected` : `${stats.totalPorts} open`}
)}
)}
);
}
function PortCard({ port }) {
const cable = cableByPortId.get(port.id);
const oppositePortId = cable ? (cable.fromPortId === port.id ? cable.toPortId : cable.fromPortId) : "";
const oppositePort = oppositePortId ? portsById.get(oppositePortId) : null;
const isDraftSource = portDraft === port.id;
const cardClass = cable
? "border-emerald-500/40 bg-emerald-500/10"
: isDraftSource
? "border-cyan-400/60 bg-cyan-500/10"
: "border-slate-800 bg-slate-950/50 hover:bg-slate-950/70";
return (
{port.sideShort} - {port.shortLabel}
{cable && oppositePort
? `Connected to ${oppositePort.deviceName} / ${oppositePort.label}`
: isDraftSource
? "Cable source selected"
: "Open port"}
{cable ? (
) : (
)}
{cable && (
{cable.label || cable.id}
{!!cable.notes && (
{cable.notes}
)}
)}
);
}
return (
{toast && (
)}
Inventory
{templateView.length} template(s)
setInvFilter(e.target.value)}
placeholder="Search templates, notes, or ports..."
className="w-full px-3 py-2 rounded bg-slate-950/50 border border-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
Ports accept multiple groups separated by semicolons or new lines.
Front-RJ45:24;Rear-AC:2
{importing ? "Importing templates..." : "Drag templates into the rack. Select placed devices to manage ports and cables."}
{templateView.map((item) => (
{
if (isInteractive(e.target)) return;
e.preventDefault();
startTemplateDrag(item.id, e);
}}
title="Drag into the rack"
>
{item.name}
{item.u}U {portsSummary(item) ? `- ${portsSummary(item)}` : ""}
{!!item.notes.trim() && (
{item.notes}
)}
))}
{!templateView.length && (
No templates match your filter.
)}
Rack Workspace
Active: {activeRack?.name ?? "Rack"} | Devices: {placedList.length} | Height: {rackU}U | Cables: {enrichedCables.length}
Rack
Rack U
{
const nextRackU = clamp(safeInt(e.target.value, rackU), 1, 60);
updateActiveRack(rack => {
const nextPlacements = {};
for (const [pid, start] of Object.entries(rack.placements || {})) {
const item = (rack.placedItems || []).find(x => x.id === pid);
if (!item) continue;
const maxStart = Math.max(1, nextRackU - item.u + 1);
nextPlacements[pid] = clamp(safeInt(start, 1), 1, maxStart);
}
return { ...rack, rackU: nextRackU, placements: nextPlacements };
});
}}
className="w-20 px-2 py-1 text-sm rounded bg-slate-900 border border-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
{Array.from({ length: rackU }).map((_, idx) => {
const u = rackU - idx;
const isHover = dragSession?.hoverU === u;
return (
{u}
);
})}
{Array.from({ length: rackU }).map((_, idx) => (
))}
{dragSession?.previewStartU != null && (
)}
{placedList.map(record => (
))}
{dragSession?.previewStartU != null && (() => {
if (dragSession.kind === "template") {
const template = templates.find(item => item.id === dragSession.templateId);
if (!template) return null;
return (
);
}
const original = placedItems.find(item => item.id === dragSession.itemId);
if (!original) return null;
return (
);
})()}
Dragging now uses a preview-first drop flow, so moving gear does not jitter around the rack while you drag.
Each placed device exposes individual front and rear ports in the manager panel, and port dots on the rack show live connection status.
XLSX export now includes BOM, Placements, Port Map, and Connections so cable endpoints stay tracked.
Port Manager
{selectedPlaced
? `${selectedPlaced.item.name} at U${selectedPlaced.startU}-${selectedPlaced.endU}`
: "Select a placed device to browse ports"}
{selectedPlaced ? (
<>
Device Size
{selectedPlaced.item.u}U
Ports
{selectedPorts.length}
Connected
{(itemConnectionStats.get(selectedPlaced.itemId)?.connectedPorts) || 0}
Front Ports
{selectedFrontPorts.length ? (
{selectedFrontPorts.map(port =>
)}
) : (
No front ports defined for this device.
)}
Rear Ports
{selectedRearPorts.length ? (
{selectedRearPorts.map(port =>
)}
) : (
No rear ports defined for this device.
)}
>
) : (
Place a device in the rack, then select it here to manage its ports.
)}
Cable Builder
Start from any open port, then finish the connection by clicking another open port or using the target selector.
{portDraft && (
)}
Source Port
{activePortDraftInfo
? `${activePortDraftInfo.deviceName} / ${activePortDraftInfo.label}`
: "No source port selected yet"}
setCableLabel(e.target.value)}
placeholder="Optional cable label"
className="w-full px-3 py-2 rounded bg-slate-950/50 border border-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
Rack Connections
{enrichedCables.length} cable(s) on the active rack
{enrichedCables.length ? (
{enrichedCables.map(cable => (
{cable.label || cable.id}
({cable.type || "Patch"})
{cable.fromPort.deviceName} / {cable.fromPort.label}
{cable.toPort.deviceName} / {cable.toPort.label}
{!!cable.notes && (
{cable.notes}
)}
))}
) : (
No cables yet. Start from a port in the port manager to build a tracked connection.
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();