feat(*): Track cable type, colour and length on port connections

This commit is contained in:
Josh Creek
2026-07-12 20:22:45 +01:00
parent 2144eda352
commit b4c7d1bce4
7 changed files with 352 additions and 13 deletions
+183 -1
View File
@@ -15,6 +15,7 @@
deletePort, deletePort,
renameOwner, renameOwner,
renamePort, renamePort,
setPortCable,
setPortConnection, setPortConnection,
type OwnerKind, type OwnerKind,
withReconciledConnections withReconciledConnections
@@ -25,7 +26,7 @@
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema'; import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData'; import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
import { computeSearchMatches } from '$lib/graph/searchHighlight'; import { computeSearchMatches } from '$lib/graph/searchHighlight';
import type { GraphNodeData, GraphTransformResult } from '$lib/graph/types'; import type { GraphEdgeData, GraphNodeData, GraphTransformResult } from '$lib/graph/types';
import { themeMode, type ThemeMode } from '$lib/stores/theme'; import { themeMode, type ThemeMode } from '$lib/stores/theme';
import type { NetworkData, Port } from '$lib/types'; import type { NetworkData, Port } from '$lib/types';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
@@ -571,6 +572,12 @@
color: theme === 'dark' ? '#94a3b8' : '#64748b' color: theme === 'dark' ? '#94a3b8' : '#64748b'
} }
}, },
{
selector: 'edge[cableColor]',
style: {
'line-color': 'data(cableColor)'
}
},
{ {
selector: 'node[vlanColor]', selector: 'node[vlanColor]',
style: { style: {
@@ -1417,6 +1424,31 @@
updateDevicePortConnection(deviceIndex, portIndex, next); updateDevicePortConnection(deviceIndex, portIndex, next);
} }
function updatePortCableField(
kind: OwnerKind,
ownerName: string,
port: Port,
field: 'type' | 'color' | 'lengthM',
value: string
) {
const current: NonNullable<NonNullable<Port['connectedTo']>['cable']> = {
...(port.connectedTo?.cable ?? {})
};
if (field === 'lengthM') {
const parsed = Number(value);
if (value.trim() && Number.isFinite(parsed) && parsed >= 0) {
current.lengthM = parsed;
} else {
delete current.lengthM;
}
} else if (value.trim()) {
current[field] = value.trim();
} else {
delete current[field];
}
applyDraft(setPortCable(networkData, kind, ownerName, port.portName, current));
}
function deleteMachine(machineIndex: number) { function deleteMachine(machineIndex: number) {
const machine = networkData.machines[machineIndex]; const machine = networkData.machines[machineIndex];
if (!machine) { if (!machine) {
@@ -1734,6 +1766,33 @@
tooltip.visible = false; tooltip.visible = false;
}); });
cy.on('mouseover', 'edge[kind = "physical"]', (event) => {
const edge = event.target;
const data = edge.data() as GraphEdgeData;
const parts = [
`${data.sourcePort ?? '?'}${data.targetPort ?? '?'}`,
...(data.speedGbps ? [`${data.speedGbps}GbE`] : []),
...(data.cableType ? [data.cableType] : []),
...(data.cableColorName ? [data.cableColorName] : []),
...(typeof data.cableLengthM === 'number' ? [`${data.cableLengthM}m`] : [])
];
const midpoint = edge.renderedMidpoint();
const clampedPosition = clampTooltipPosition(
midpoint.x + tooltipOffset.x,
midpoint.y + tooltipOffset.y
);
tooltip = {
visible: true,
text: parts.join(' · '),
x: clampedPosition.x,
y: clampedPosition.y
};
});
cy.on('mouseout', 'edge', () => {
tooltip.visible = false;
});
cy.on('pan zoom', () => { cy.on('pan zoom', () => {
tooltip.visible = false; tooltip.visible = false;
}); });
@@ -2150,6 +2209,27 @@
<p class="icon-results-empty">No icons match your search.</p> <p class="icon-results-empty">No icons match your search.</p>
{/if} {/if}
</div> </div>
<datalist id="cable-type-options">
<option value="Cat5e"></option>
<option value="Cat6"></option>
<option value="Cat6a"></option>
<option value="Cat7"></option>
<option value="Cat8"></option>
<option value="DAC"></option>
<option value="Fibre"></option>
</datalist>
<datalist id="cable-color-options">
<option value="blue"></option>
<option value="red"></option>
<option value="green"></option>
<option value="yellow"></option>
<option value="orange"></option>
<option value="purple"></option>
<option value="pink"></option>
<option value="white"></option>
<option value="grey"></option>
<option value="black"></option>
</datalist>
{#if selectedTarget?.type === 'machine' && selectedMachine} {#if selectedTarget?.type === 'machine' && selectedMachine}
<div class="inline-actions"> <div class="inline-actions">
{#if selectedMachineHostId && selectedMachineVmCount > 0} {#if selectedMachineHostId && selectedMachineVmCount > 0}
@@ -2452,6 +2532,57 @@
)} )}
/> />
</label> </label>
{#if port.connectedTo}
<label>
Cable Type
<input
type="text"
list="cable-type-options"
value={port.connectedTo.cable?.type ?? ''}
on:change={(event) =>
updatePortCableField(
'machine',
selectedMachine.machineName,
port,
'type',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
<label>
Cable Colour
<input
type="text"
list="cable-color-options"
value={port.connectedTo.cable?.color ?? ''}
on:change={(event) =>
updatePortCableField(
'machine',
selectedMachine.machineName,
port,
'color',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
<label>
Cable Length (m)
<input
type="number"
min="0"
step="0.5"
value={port.connectedTo.cable?.lengthM ?? ''}
on:change={(event) =>
updatePortCableField(
'machine',
selectedMachine.machineName,
port,
'lengthM',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
{/if}
<div class="item-actions"> <div class="item-actions">
<button <button
type="button" type="button"
@@ -2628,6 +2759,57 @@
)} )}
/> />
</label> </label>
{#if port.connectedTo}
<label>
Cable Type
<input
type="text"
list="cable-type-options"
value={port.connectedTo.cable?.type ?? ''}
on:change={(event) =>
updatePortCableField(
'device',
selectedDevice.name,
port,
'type',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
<label>
Cable Colour
<input
type="text"
list="cable-color-options"
value={port.connectedTo.cable?.color ?? ''}
on:change={(event) =>
updatePortCableField(
'device',
selectedDevice.name,
port,
'color',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
<label>
Cable Length (m)
<input
type="number"
min="0"
step="0.5"
value={port.connectedTo.cable?.lengthM ?? ''}
on:change={(event) =>
updatePortCableField(
'device',
selectedDevice.name,
port,
'lengthM',
(event.currentTarget as HTMLInputElement).value
)}
/>
</label>
{/if}
<div class="item-actions"> <div class="item-actions">
<button <button
type="button" type="button"
+58 -3
View File
@@ -1,4 +1,4 @@
import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types'; import type { CableInfo, Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
export type OwnerKind = 'machine' | 'device'; export type OwnerKind = 'machine' | 'device';
@@ -119,9 +119,10 @@ function ensureReciprocalConnections(data: NetworkData) {
continue; continue;
} }
const expectedReciprocal = { const expectedReciprocal: Port['connectedTo'] = {
device: owner.name, device: owner.name,
port: port.portName port: port.portName,
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
}; };
if (!targetPort.connectedTo) { if (!targetPort.connectedTo) {
targetPort.connectedTo = expectedReciprocal; targetPort.connectedTo = expectedReciprocal;
@@ -132,6 +133,19 @@ function ensureReciprocalConnections(data: NetworkData) {
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) && equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(targetPort.connectedTo.port, port.portName); equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
if (hasMatchingReciprocal) { if (hasMatchingReciprocal) {
// keep cable metadata identical on both ends; the lexicographically
// smaller owner:port key is the canonical copy so the sync is deterministic
const ownKey = `${owner.name}:${port.portName}`.toLowerCase();
const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase();
const [from, to] =
ownKey <= targetKey
? [port.connectedTo, targetPort.connectedTo]
: [targetPort.connectedTo, port.connectedTo];
if (from.cable) {
to.cable = deepClone(from.cable);
} else {
delete to.cable;
}
continue; continue;
} }
} }
@@ -330,6 +344,47 @@ export function deletePort(
return cloned; return cloned;
} }
export function setPortCable(
data: NetworkData,
kind: OwnerKind,
ownerName: string,
portName: string,
cable: CableInfo | undefined
): NetworkData {
const cloned = cloneNetworkData(data);
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
const port = owner ? findPort(owner, portName) : undefined;
if (!owner || !port?.connectedTo) {
return cloned;
}
const entries = Object.entries(cable ?? {}).filter(([, value]) =>
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
);
const normalized = entries.length > 0 ? (Object.fromEntries(entries) as CableInfo) : undefined;
if (normalized) {
port.connectedTo.cable = deepClone(normalized);
} else {
delete port.connectedTo.cable;
}
const peerOwner = findOwnerByName(cloned, port.connectedTo.device);
const peerPort = peerOwner ? findPort(peerOwner, port.connectedTo.port) : undefined;
if (
peerPort?.connectedTo &&
equalsIgnoreCase(peerPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(peerPort.connectedTo.port, port.portName)
) {
if (normalized) {
peerPort.connectedTo.cable = deepClone(normalized);
} else {
delete peerPort.connectedTo.cable;
}
}
return cloned;
}
export function setPortConnection( export function setPortConnection(
data: NetworkData, data: NetworkData,
kind: OwnerKind, kind: OwnerKind,
+28 -1
View File
@@ -57,6 +57,28 @@ function connectionKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`; return a < b ? `${a}|${b}` : `${b}|${a}`;
} }
const cableColorHex: Record<string, string> = {
blue: '#3b82f6',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
orange: '#f97316',
purple: '#a855f7',
pink: '#ec4899',
white: '#e2e8f0',
grey: '#94a3b8',
gray: '#94a3b8',
black: '#334155'
};
function resolveCableColor(name: string | undefined): string | undefined {
if (!name) {
return undefined;
}
const trimmed = name.trim();
return cableColorHex[trimmed.toLowerCase()] ?? (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined);
}
function buildVlanResolver(subnets: Subnet[] | undefined) { function buildVlanResolver(subnets: Subnet[] | undefined) {
const vlanSubnets = (subnets ?? []) const vlanSubnets = (subnets ?? [])
.filter((subnet) => typeof subnet.vlanId === 'number') .filter((subnet) => typeof subnet.vlanId === 'number')
@@ -315,6 +337,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
physicalEdgeCounter += 1; physicalEdgeCounter += 1;
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps); const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
edges.push({ edges.push({
data: { data: {
@@ -326,7 +349,11 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
sourcePort: port.portName, sourcePort: port.portName,
targetPort: connectedTo.port, targetPort: connectedTo.port,
speedGbps, speedGbps,
reciprocal reciprocal,
...(cable?.type ? { cableType: cable.type } : {}),
...(cable?.color ? { cableColorName: cable.color } : {}),
...(resolveCableColor(cable?.color) ? { cableColor: resolveCableColor(cable?.color) } : {}),
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
} }
}); });
} }
+9
View File
@@ -8,6 +8,11 @@ export interface PortDetails {
connectedTo?: { connectedTo?: {
device: string; device: string;
port: string; port: string;
cable?: {
type?: string;
color?: string;
lengthM?: number;
};
}; };
} }
@@ -79,6 +84,10 @@ export interface GraphEdgeData {
targetPort?: string; targetPort?: string;
speedGbps?: number; speedGbps?: number;
reciprocal?: boolean; reciprocal?: boolean;
cableType?: string;
cableColorName?: string;
cableColor?: string;
cableLengthM?: number;
} }
export interface GraphNodeElement { export interface GraphNodeElement {
+31 -2
View File
@@ -1,6 +1,7 @@
/** /**
* @typedef {{ path: string; message: string }} ValidationIssue * @typedef {{ path: string; message: string }} ValidationIssue
* @typedef {{ device: string; port: string }} ConnectedTo * @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port * @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM * @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware * @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
@@ -118,8 +119,36 @@ export function normalizePort(issues, path, value, ownerLabel) {
} else { } else {
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device); const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port); const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
let cable;
const cableRaw = value.connectedTo.cable;
if (cableRaw !== undefined) {
if (!isRecord(cableRaw)) {
issues.push({ path: `${path}.connectedTo.cable`, message: 'must be an object' });
} else {
const cableType = readString(issues, `${path}.connectedTo.cable.type`, cableRaw.type, {
optional: true,
allowEmpty: true
});
const cableColor = readString(issues, `${path}.connectedTo.cable.color`, cableRaw.color, {
optional: true,
allowEmpty: true
});
const lengthM = readNumber(issues, `${path}.connectedTo.cable.lengthM`, cableRaw.lengthM, {
optional: true,
min: 0
});
const candidate = {
...(cableType ? { type: cableType } : {}),
...(cableColor ? { color: cableColor } : {}),
...(typeof lengthM === 'number' ? { lengthM } : {})
};
if (Object.keys(candidate).length > 0) {
cable = candidate;
}
}
}
if (device && port) { if (device && port) {
connectedTo = { device, port }; connectedTo = { device, port, ...(cable ? { cable } : {}) };
} }
} }
} }
+7
View File
@@ -1,6 +1,13 @@
export interface CableInfo {
type?: string;
color?: string;
lengthM?: number;
}
export interface ConnectedPortRef { export interface ConnectedPortRef {
device: string; device: string;
port: string; port: string;
cable?: CableInfo;
} }
export interface Port { export interface Port {
+36 -6
View File
@@ -55,7 +55,12 @@
"macAddress": "84:47:09:1c:aa:10", "macAddress": "84:47:09:1c:aa:10",
"connectedTo": { "connectedTo": {
"device": "Gigabit Switch", "device": "Gigabit Switch",
"port": "1" "port": "1",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
} }
}, },
{ {
@@ -67,7 +72,12 @@
"speedGbps": 1, "speedGbps": 1,
"connectedTo": { "connectedTo": {
"device": "Gigabit Switch", "device": "Gigabit Switch",
"port": "2" "port": "2",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
} }
}, },
{ {
@@ -101,7 +111,12 @@
"speedGbps": 1, "speedGbps": 1,
"connectedTo": { "connectedTo": {
"device": "Gigabit Switch", "device": "Gigabit Switch",
"port": "3" "port": "3",
"cable": {
"type": "Cat5e",
"color": "red",
"lengthM": 2
}
} }
} }
] ]
@@ -337,7 +352,12 @@
"speedGbps": 1, "speedGbps": 1,
"connectedTo": { "connectedTo": {
"device": "ProxRouter", "device": "ProxRouter",
"port": "eth0" "port": "eth0",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
} }
}, },
{ {
@@ -345,7 +365,12 @@
"speedGbps": 1, "speedGbps": 1,
"connectedTo": { "connectedTo": {
"device": "ProxRouter", "device": "ProxRouter",
"port": "eth2" "port": "eth2",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
} }
}, },
{ {
@@ -353,7 +378,12 @@
"speedGbps": 1, "speedGbps": 1,
"connectedTo": { "connectedTo": {
"device": "Asustor NAS", "device": "Asustor NAS",
"port": "eth0" "port": "eth0",
"cable": {
"type": "Cat5e",
"color": "red",
"lengthM": 2
}
} }
}, },
{ {