feat(#2): replace card layout with cytoscape network graph

This commit is contained in:
Josh Creek
2026-02-22 12:56:09 +00:00
parent ae16345ea2
commit a9e0bd15a8
15 changed files with 8361 additions and 649 deletions
+264
View File
@@ -0,0 +1,264 @@
import type { ConnectedPortRef, NetworkData, Port } from '../types';
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
interface EndpointOwner {
nodeId: string;
name: string;
ports: Map<string, Port>;
}
function toMachineNodeId(name: string): string {
return `machine:${encodeURIComponent(name)}`;
}
function toDeviceNodeId(name: string): string {
return `device:${encodeURIComponent(name)}`;
}
function toVmNodeId(hostName: string, vmName: string): string {
return `vm:${encodeURIComponent(hostName)}:${encodeURIComponent(vmName)}`;
}
function parseConnectedTo(connection: Port['connectedTo']): ConnectedPortRef | null {
if (!connection?.device || !connection?.port) return null;
return {
device: connection.device.trim(),
port: connection.port.trim()
};
}
function hasReciprocalLink(
targetPort: Port | undefined,
expectedDeviceName: string,
expectedPortName: string
): boolean {
const reverse = parseConnectedTo(targetPort?.connectedTo);
if (!reverse) return false;
return reverse.device === expectedDeviceName && reverse.port === expectedPortName;
}
function connectionKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
if (typeof a === 'number' && typeof b === 'number') return Math.min(a, b);
return typeof a === 'number' ? a : b;
}
export function transformNetworkDataToGraph(data: NetworkData): GraphTransformResult {
const nodes: GraphNodeElement[] = [];
const edges: GraphEdgeElement[] = [];
const warnings: string[] = [];
const machineVmIndex: Record<
string,
{
vmNodeIds: string[];
hostingEdgeIds: string[];
vmCount: number;
}
> = {};
const ownersByName = new Map<string, EndpointOwner>();
const seenWarnings = new Set<string>();
const warn = (message: string) => {
if (!seenWarnings.has(message)) {
seenWarnings.add(message);
warnings.push(message);
}
};
const addOwner = (owner: EndpointOwner) => {
if (ownersByName.has(owner.name)) {
warn(
`Duplicate node name "${owner.name}" found. First occurrence will be used for link resolution.`
);
return;
}
ownersByName.set(owner.name, owner);
};
let hostingEdgeCounter = 0;
for (const machine of data.machines) {
const machineNodeId = toMachineNodeId(machine.machineName);
const machinePortsList = [...(machine.ports ?? [])];
const machineVms = machine.software.vms.map((vm) => ({
name: vm.name,
ip: vm.ipAddress,
role: vm.role
}));
nodes.push({
data: {
id: machineNodeId,
label: machine.machineName,
rawName: machine.machineName,
kind: 'machine',
nodeWidth: 200,
nodeHeight: 110,
details: {
type: 'machine',
name: machine.machineName,
ip: machine.ipAddress,
role: machine.role,
os: machine.operatingSystem,
cpu: machine.hardware.cpu,
ram: machine.hardware.ram,
gpu: machine.hardware.gpu,
ports: machinePortsList,
vmCount: machineVms.length,
vms: machineVms
}
}
});
machineVmIndex[machineNodeId] = {
vmNodeIds: [],
hostingEdgeIds: [],
vmCount: machine.software.vms.length
};
for (const vm of machine.software.vms) {
const vmNodeId = toVmNodeId(machine.machineName, vm.name);
nodes.push({
data: {
id: vmNodeId,
label: vm.name,
rawName: vm.name,
kind: 'vm',
hostMachineId: machineNodeId,
nodeWidth: 140,
nodeHeight: 66,
details: {
type: 'vm',
name: vm.name,
ip: vm.ipAddress,
role: vm.role,
hostName: machine.machineName
}
}
});
hostingEdgeCounter += 1;
const hostingEdgeId = `hosting:${hostingEdgeCounter}`;
edges.push({
data: {
id: hostingEdgeId,
source: machineNodeId,
target: vmNodeId,
kind: 'hosting',
label: 'hosts'
}
});
machineVmIndex[machineNodeId].vmNodeIds.push(vmNodeId);
machineVmIndex[machineNodeId].hostingEdgeIds.push(hostingEdgeId);
}
const machinePorts = new Map<string, Port>();
for (const port of machine.ports ?? []) {
machinePorts.set(port.portName, port);
}
addOwner({
nodeId: machineNodeId,
name: machine.machineName,
ports: machinePorts
});
}
for (const device of data.devices) {
const deviceNodeId = toDeviceNodeId(device.name);
const devicePorts = [...(device.ports ?? [])];
nodes.push({
data: {
id: deviceNodeId,
label: device.name,
rawName: device.name,
kind: 'device',
nodeWidth: 176,
nodeHeight: 116,
details: {
type: 'device',
name: device.name,
ip: device.ipAddress,
deviceType: device.type,
notes: device.notes,
ports: devicePorts
}
}
});
const devicePortMap = new Map<string, Port>();
for (const port of device.ports ?? []) {
devicePortMap.set(port.portName, port);
}
addOwner({
nodeId: deviceNodeId,
name: device.name,
ports: devicePortMap
});
}
const seenPhysicalConnections = new Set<string>();
let physicalEdgeCounter = 0;
for (const owner of ownersByName.values()) {
for (const port of owner.ports.values()) {
const connectedTo = parseConnectedTo(port.connectedTo);
if (!connectedTo) continue;
const targetOwner = ownersByName.get(connectedTo.device);
if (!targetOwner) {
warn(
`Link from "${owner.name}:${port.portName}" references unknown device "${connectedTo.device}".`
);
continue;
}
const targetPort = targetOwner.ports.get(connectedTo.port);
if (!targetPort) {
warn(
`Link from "${owner.name}:${port.portName}" references unknown port "${connectedTo.device}:${connectedTo.port}".`
);
continue;
}
const a = `${owner.nodeId}:${port.portName}`;
const b = `${targetOwner.nodeId}:${connectedTo.port}`;
const dedupeKey = connectionKey(a, b);
if (seenPhysicalConnections.has(dedupeKey)) continue;
seenPhysicalConnections.add(dedupeKey);
const reciprocal = hasReciprocalLink(targetPort, owner.name, port.portName);
if (!reciprocal) {
warn(
`Non-reciprocal link: "${owner.name}:${port.portName}" points to "${connectedTo.device}:${connectedTo.port}" but reverse link is missing.`
);
}
physicalEdgeCounter += 1;
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
edges.push({
data: {
id: `physical:${physicalEdgeCounter}`,
source: owner.nodeId,
target: targetOwner.nodeId,
kind: 'physical',
label: speedGbps ? `${speedGbps}GbE` : undefined,
sourcePort: port.portName,
targetPort: connectedTo.port,
speedGbps,
reciprocal
}
});
}
}
return { nodes, edges, warnings, machineVmIndex };
}