import { useCallback, useRef, useState, useEffect } from "react";
import {
ReactFlow,
addEdge,
useNodesState,
useEdgesState,
Controls,
Background,
MiniMap,
useReactFlow,
getNodesBounds,
getViewportForBounds,
type Connection,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import * as XLSX from "xlsx";
import { toPng } from "html-to-image";
import { Button } from "@/components/ui/button";
import { Network, ChevronDown, Image, FileSpreadsheet, Plus, Users, LayoutTemplate, Pencil, Trash2, MoreVertical, LogOut, Menu, X } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { useIsMobile } from "@/hooks/use-mobile";
import DeviceNode, { type DeviceData } from "@/components/DeviceNode";
import AddDeviceDialog, { type DeviceInfo } from "@/components/AddDeviceDialog";
import EditDeviceDialog, { type EditableDevice } from "@/components/EditDeviceDialog";
const nodeTypes = { device: DeviceNode };
type ClientData = {
name: string;
devices: (DeviceInfo & { id: string })[];
nodes: Node[];
edges: Edge[];
};
const templateDevices: (DeviceInfo & { id: string })[] = [
{ id: "t-isp", name: "Internet / Fibre", ip: "WAN", type: "internet", customFields: {} },
{ id: "t-fw1", name: "Firewall", ip: "10.0.0.1", type: "firewall", customFields: {} },
{ id: "t-router1", name: "Core Router", ip: "10.0.0.2", type: "router", customFields: {} },
{ id: "t-sw1", name: "Switch โ Floor 1", ip: "10.0.1.1", type: "switch", customFields: {} },
{ id: "t-sw2", name: "Switch โ Floor 2", ip: "10.0.2.1", type: "switch", customFields: {} },
{ id: "t-srv1", name: "Domain Controller", ip: "10.0.1.10", type: "server", customFields: {} },
{ id: "t-nas1", name: "NAS Storage", ip: "10.0.1.15", type: "nas", customFields: {} },
{ id: "t-ap1", name: "Wi-Fi AP", ip: "10.0.2.20", type: "accesspoint", customFields: {} },
{ id: "t-pc1", name: "Front Desk PC", ip: "DHCP", type: "desktop", customFields: {} },
{ id: "t-lt1", name: "Manager Laptop", ip: "DHCP", type: "laptop", customFields: {} },
{ id: "t-pr1", name: "Office Printer", ip: "10.0.1.30", type: "printer", customFields: {} },
{ id: "t-cam1", name: "Security Camera", ip: "10.0.2.50", type: "camera", customFields: {} },
];
const templateNodes: Node[] = [
{ id: "t-isp", type: "device", position: { x: 300, y: 0 }, data: { label: "Internet / Fibre", ip: "WAN", deviceType: "internet" } },
{ id: "t-fw1", type: "device", position: { x: 300, y: 90 }, data: { label: "Firewall", ip: "10.0.0.1", deviceType: "firewall" } },
{ id: "t-router1", type: "device", position: { x: 300, y: 180 }, data: { label: "Core Router", ip: "10.0.0.2", deviceType: "router" } },
{ id: "t-sw1", type: "device", position: { x: 120, y: 290 }, data: { label: "Switch โ Floor 1", ip: "10.0.1.1", deviceType: "switch" } },
{ id: "t-sw2", type: "device", position: { x: 480, y: 290 }, data: { label: "Switch โ Floor 2", ip: "10.0.2.1", deviceType: "switch" } },
{ id: "t-srv1", type: "device", position: { x: 0, y: 410 }, data: { label: "Domain Controller", ip: "10.0.1.10", deviceType: "server" } },
{ id: "t-nas1", type: "device", position: { x: 160, y: 410 }, data: { label: "NAS Storage", ip: "10.0.1.15", deviceType: "nas" } },
{ id: "t-pr1", type: "device", position: { x: 310, y: 410 }, data: { label: "Office Printer", ip: "10.0.1.30", deviceType: "printer" } },
{ id: "t-ap1", type: "device", position: { x: 420, y: 410 }, data: { label: "Wi-Fi AP", ip: "10.0.2.20", deviceType: "accesspoint" } },
{ id: "t-cam1", type: "device", position: { x: 570, y: 410 }, data: { label: "Security Camera", ip: "10.0.2.50", deviceType: "camera" } },
{ id: "t-pc1", type: "device", position: { x: 370, y: 520 }, data: { label: "Front Desk PC", ip: "DHCP", deviceType: "desktop" } },
{ id: "t-lt1", type: "device", position: { x: 520, y: 520 }, data: { label: "Manager Laptop", ip: "DHCP", deviceType: "laptop" } },
];
const templateEdges: Edge[] = [
{ id: "te0", source: "t-isp", target: "t-fw1", animated: true },
{ id: "te1", source: "t-fw1", target: "t-router1", animated: true },
{ id: "te2", source: "t-router1", target: "t-sw1", animated: true },
{ id: "te3", source: "t-router1", target: "t-sw2", animated: true },
{ id: "te4", source: "t-sw1", target: "t-srv1", animated: true },
{ id: "te5", source: "t-sw1", target: "t-nas1", animated: true },
{ id: "te6", source: "t-sw1", target: "t-pr1", animated: true },
{ id: "te7", source: "t-sw2", target: "t-ap1", animated: true },
{ id: "te8", source: "t-sw2", target: "t-cam1", animated: true },
{ id: "te9", source: "t-ap1", target: "t-pc1", animated: true },
{ id: "te10", source: "t-ap1", target: "t-lt1", animated: true },
];
function cloneTemplate() {
const idMap: Record
= {};
const devices = templateDevices.map((d) => {
const newId = `node-${counter++}`;
idMap[d.id] = newId;
return { ...d, id: newId };
});
const nodes = templateNodes.map((n) => ({
...n,
id: idMap[n.id],
data: { ...n.data },
}));
const edges = templateEdges.map((e) => ({
...e,
id: `e-${counter++}`,
source: idMap[e.source],
target: idMap[e.target],
}));
return { devices, nodes, edges };
}
const defaultDevices = templateDevices;
const defaultNodes = templateNodes;
const defaultEdges = templateEdges;
const initialClients: Record = {
"demo-client": {
name: "Demo Client",
devices: [...defaultDevices],
nodes: defaultNodes,
edges: defaultEdges,
},
};
let counter = 10;
const STORAGE_KEY = "topoglyph_data";
const ACTIVE_KEY = "topoglyph_active";
function loadFromStorage(): { clients: Record; activeId: string } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const activeId = localStorage.getItem(ACTIVE_KEY);
if (raw) {
const clients = JSON.parse(raw) as Record;
return { clients, activeId: activeId && clients[activeId] ? activeId : Object.keys(clients)[0] };
}
} catch { /* ignore */ }
return null;
}
function saveToStorage(clients: Record, activeId: string) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(clients));
localStorage.setItem(ACTIVE_KEY, activeId);
} catch { /* ignore */ }
}
function IndexInner() {
const isMobile = useIsMobile();
const [sidebarOpen, setSidebarOpen] = useState(false);
const stored = useRef(loadFromStorage());
const [clients, setClients] = useState>(stored.current?.clients ?? initialClients);
const [activeClientId, setActiveClientId] = useState(stored.current?.activeId ?? "demo-client");
const [addClientOpen, setAddClientOpen] = useState(false);
const [newClientName, setNewClientName] = useState("");
const [useTemplate, setUseTemplate] = useState(true);
const activeClient = clients[activeClientId];
const [nodes, setNodes, onNodesChange] = useNodesState(activeClient.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(activeClient.edges);
const devicesRef = useRef<(DeviceInfo & { id: string })[]>([...activeClient.devices]);
const canvasRef = useRef(null);
const [editDevice, setEditDevice] = useState(null);
const [editOpen, setEditOpen] = useState(false);
// Auto-save to localStorage
useEffect(() => {
const updated = {
...clients,
[activeClientId]: {
...clients[activeClientId],
devices: [...devicesRef.current],
nodes: [...nodes],
edges: [...edges],
},
};
saveToStorage(updated, activeClientId);
}, [clients, activeClientId, nodes, edges]);
// Save current client state before switching
const saveCurrentClient = useCallback(() => {
setClients((prev) => ({
...prev,
[activeClientId]: {
...prev[activeClientId],
devices: [...devicesRef.current],
nodes: [...nodes],
edges: [...edges],
},
}));
}, [activeClientId, nodes, edges]);
const switchClient = useCallback((clientId: string) => {
// Save current
setClients((prev) => {
const updated = {
...prev,
[activeClientId]: {
...prev[activeClientId],
devices: [...devicesRef.current],
nodes: [...nodes],
edges: [...edges],
},
};
// Load new client
const newClient = updated[clientId];
devicesRef.current = [...newClient.devices];
setNodes(newClient.nodes);
setEdges(newClient.edges);
return updated;
});
setActiveClientId(clientId);
}, [activeClientId, nodes, edges, setNodes, setEdges]);
const handleAddClient = () => {
if (!newClientName.trim()) return;
const id = `client-${Date.now()}`;
saveCurrentClient();
let newData: { devices: (DeviceInfo & { id: string })[]; nodes: Node[]; edges: Edge[] };
if (useTemplate) {
newData = cloneTemplate();
} else {
newData = { devices: [], nodes: [], edges: [] };
}
setClients((prev) => ({
...prev,
[id]: { name: newClientName.trim(), ...newData },
}));
setActiveClientId(id);
devicesRef.current = [...newData.devices];
setNodes(newData.nodes);
setEdges(newData.edges);
setNewClientName("");
setUseTemplate(true);
setAddClientOpen(false);
};
const loadDemoTemplate = () => {
const { devices, nodes: tNodes, edges: tEdges } = cloneTemplate();
devicesRef.current = [...devicesRef.current, ...devices];
setNodes((nds) => [...nds, ...tNodes]);
setEdges((eds) => [...eds, ...tEdges]);
};
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true }, eds)),
[setEdges]
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
const device = devicesRef.current.find((d) => d.id === node.id);
if (device) {
setEditDevice({ ...device });
setEditOpen(true);
}
}, []);
const handleSaveDevice = (updated: EditableDevice) => {
devicesRef.current = devicesRef.current.map((d) => (d.id === updated.id ? updated : d));
setNodes((nds) =>
nds.map((n) =>
n.id === updated.id
? { ...n, data: { label: updated.name, ip: updated.ip, deviceType: updated.type } as DeviceData }
: n
)
);
};
const handleDeleteDevice = (id: string) => {
devicesRef.current = devicesRef.current.filter((d) => d.id !== id);
setNodes((nds) => nds.filter((n) => n.id !== id));
setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id));
};
const handleAddDevice = (device: DeviceInfo) => {
const id = `node-${counter++}`;
const newNode: Node = {
id,
type: "device",
position: { x: 100 + Math.random() * 400, y: 100 + Math.random() * 300 },
data: { label: device.name, ip: device.ip, deviceType: device.type } as DeviceData,
};
setNodes((nds) => [...nds, newNode]);
devicesRef.current.push({ ...device, id });
};
const [renameClientId, setRenameClientId] = useState(null);
const [renameValue, setRenameValue] = useState("");
const startRenameClient = (id: string) => {
setRenameClientId(id);
setRenameValue(clients[id].name);
};
const confirmRenameClient = () => {
if (!renameClientId || !renameValue.trim()) return;
setClients((prev) => ({
...prev,
[renameClientId]: { ...prev[renameClientId], name: renameValue.trim() },
}));
setRenameClientId(null);
};
const deleteClient = (id: string) => {
const keys = Object.keys(clients);
if (keys.length <= 1) return; // don't delete the last client
setClients((prev) => {
const copy = { ...prev };
delete copy[id];
return copy;
});
if (activeClientId === id) {
const remaining = keys.filter((k) => k !== id);
const newId = remaining[0];
devicesRef.current = [...clients[newId].devices];
setNodes(clients[newId].nodes);
setEdges(clients[newId].edges);
setActiveClientId(newId);
}
};
const exportExcel = () => {
const rows = devicesRef.current.map((d) => {
const flat: Record = { Name: d.name, IP: d.ip, Type: d.type };
Object.entries(d.customFields).forEach(([k, v]) => {
flat[k] = typeof v === "string" ? v : v.value;
});
return flat;
});
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Devices");
XLSX.writeFile(wb, `${activeClient.name.replace(/s+/g, "_")}_devices.xlsx`);
};
const exportPNG = async () => {
if (!canvasRef.current) return;
try {
// Inline all computed styles on SVG elements so html-to-image captures edges
const svgElements = canvasRef.current.querySelectorAll("svg, path, line, polyline, marker, circle, rect, g");
svgElements.forEach((el) => {
const computed = window.getComputedStyle(el);
const important = ["stroke", "stroke-width", "stroke-dasharray", "stroke-dashoffset", "fill", "opacity", "marker-end", "marker-start"];
important.forEach((prop) => {
const val = computed.getPropertyValue(prop);
if (val && val !== "none" && val !== "") {
(el as HTMLElement).style.setProperty(prop, val);
}
});
});
const dataUrl = await toPng(canvasRef.current, {
backgroundColor: "#f5f7fa",
cacheBust: true,
pixelRatio: 2,
filter: (node) => {
if (node?.classList?.contains("react-flow__minimap")) return false;
if (node?.classList?.contains("react-flow__controls")) return false;
return true;
},
});
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${activeClient.name.replace(/s+/g, "_")}_topology.png`;
a.click();
} catch (e) {
console.error("PNG export failed", e);
}
};
const sidebarContent = (
<>
Clients
{Object.entries(clients).map(([id, client]) => (
startRenameClient(id)} className="gap-2">
Rename
{Object.keys(clients).length > 1 && (
deleteClient(id)} className="gap-2 text-destructive focus:text-destructive">
Delete
)}
))}
{/* Rename dialog */}
);
return (
{/* Desktop sidebar */}
{!isMobile && (
)}
{/* Mobile sidebar as sheet */}
{isMobile && (
{sidebarContent}
)}
{/* Main area */}
{isMobile && (
)}
TopoGlyph
{!isMobile && โ {activeClient.name}}
{!isMobile && (
)}
{/* Export dropdown */}
Image (PNG)
Report (Excel)
{isMobile && (
Load Demo
)}
{!isMobile && (
{
const dt = (n.data as DeviceData)?.deviceType;
if (dt === "router") return "hsl(215, 80%, 45%)";
if (dt === "switch") return "hsl(260, 60%, 50%)";
if (dt === "server") return "hsl(160, 60%, 42%)";
return "hsl(215, 15%, 50%)";
}}
/>
)}
);
}
export default function Index() {
return ;
}