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)
}
/**