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.name}
{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 && (
{toast.msg}
)}
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" />
Add Template
setNewName(e.target.value)} placeholder="Name" 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" />
setNewU(safeInt(e.target.value, 1))} 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" />
setNewColor(e.target.value)} className="w-full h-[42px] rounded bg-slate-950/50 border border-slate-800" />