From ea451885af7f0964ccc3658671ecc76acb1b44ef Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 15 May 2026 13:36:15 +0200 Subject: [PATCH] refactor(ip-parsing): centralize multi-delim splitting in splitIps Extend splitIps to accept comma/semicolon/newline delimiters with dedupe, and reuse it in DetailPanel for both the IP list and the service-URL host. Fixes ServiceBadge links pointing at concatenated string when IPs were entered with ';' or newline separators. Adds tests for new delimiters. --- .../src/components/panels/DetailPanel.tsx | 12 +++-------- .../panels/__tests__/DetailPanel.test.tsx | 14 +++++++++++++ frontend/src/utils/__tests__/maskIp.test.ts | 21 +++++++++++++++++++ frontend/src/utils/maskIp.ts | 9 ++++---- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 79b7d5d..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,14 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const { data } = node const services = data.services ?? [] const statusColor = STATUS_COLORS[data.status] - const host = data.ip ? primaryIp(data.ip) : data.hostname - const ipAddresses = data.ip - ? data.ip - .split(/[\n,;]+/) - .map((ip) => primaryIp(ip.trim())) - .filter(Boolean) - .filter((ip, index, all) => all.indexOf(ip) === index) - : [] + const ipAddresses = data.ip ? splitIps(data.ip) : [] + const host = ipAddresses[0] ?? data.hostname const handleDelete = () => { if (confirm(`Delete "${data.label}"?`)) { diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 11a522c..bbbd38a 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -446,6 +446,20 @@ describe('DetailPanel', () => { 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') + }) }) describe('ServiceBadge rendering', () => { 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) } /**