diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b0e902..5e526c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2520,6 +2520,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2534,6 +2537,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2548,6 +2554,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2562,6 +2571,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2576,6 +2588,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2590,6 +2605,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2604,6 +2622,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2618,6 +2639,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2632,6 +2656,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2646,6 +2673,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2660,6 +2690,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2674,6 +2707,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2688,6 +2724,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2936,6 +2975,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2953,6 +2995,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2970,6 +3015,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2987,6 +3035,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6884,6 +6935,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6905,6 +6959,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6926,6 +6983,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6947,6 +7007,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index cddd301..d8896bf 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' -import { primaryIp } from '@/utils/maskIp' +import { splitIps } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' @@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const { data } = node const services = data.services ?? [] const statusColor = STATUS_COLORS[data.status] - const host = data.ip ?? data.hostname + const ipAddresses = data.ip ? splitIps(data.ip) : [] + const host = ipAddresses[0] ?? data.hostname const handleDelete = () => { if (confirm(`Delete "${data.label}"?`)) { @@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { )} - {data.ip && ( -
- IP Address - - {data.ip} - + {ipAddresses.length > 0 && ( +
+ {ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'} +
+ {ipAddresses.map((ip, index) => ( + + + {ip} + + + + ))} +
)} {data.mac && } diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 1c12e31..bbbd38a 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -442,7 +442,23 @@ describe('DetailPanel', () => { it('displays full comma-separated IP string as link text', () => { setupStore({ ip: '192.168.1.10, 192.168.1.11' }) render() - expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined() + expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined() + expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined() + expect(screen.queryByText(',')).toBeNull() + }) + + it('renders separate links for semicolon-separated IPs', () => { + setupStore({ ip: '192.168.1.10; 192.168.1.11' }) + render() + expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10') + expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11') + }) + + it('renders separate links for newline-separated IPs', () => { + setupStore({ ip: '192.168.1.10\n192.168.1.11' }) + render() + expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10') + expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11') }) }) diff --git a/frontend/src/utils/__tests__/maskIp.test.ts b/frontend/src/utils/__tests__/maskIp.test.ts index d5f0a33..1669bcf 100644 --- a/frontend/src/utils/__tests__/maskIp.test.ts +++ b/frontend/src/utils/__tests__/maskIp.test.ts @@ -55,6 +55,27 @@ describe('splitIps', () => { expect(splitIps('')).toEqual([]) expect(splitIps(' ')).toEqual([]) }) + + it('splits on semicolons', () => { + expect(splitIps('10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2']) + }) + + it('splits on newlines', () => { + expect(splitIps('10.0.0.1\n10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2']) + }) + + it('splits on mixed delimiters', () => { + expect(splitIps('10.0.0.1,10.0.0.2; 10.0.0.3\n10.0.0.4')).toEqual([ + '10.0.0.1', + '10.0.0.2', + '10.0.0.3', + '10.0.0.4', + ]) + }) + + it('deduplicates repeated IPs', () => { + expect(splitIps('10.0.0.1, 10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2']) + }) }) describe('primaryIp', () => { diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index 8dfa9f8..ca3d5fe 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -25,16 +25,17 @@ function maskSingle(ip: string): string { */ export function maskIp(ip: string): string { if (!ip) return ip - return ip.split(',').map(maskSingle).join(', ') + return splitIps(ip).map(maskSingle).join(', ') } /** - * Split a comma-separated IP string into an array of trimmed values. - * Empty string returns []. + * Split an IP string into trimmed values. Accepts comma, semicolon, or newline + * delimiters (or any combination). Duplicates removed, empty entries dropped. */ export function splitIps(ip: string): string[] { if (!ip?.trim()) return [] - return ip.split(',').map((s) => s.trim()).filter(Boolean) + const parts = ip.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean) + return parts.filter((v, i) => parts.indexOf(v) === i) } /**