From ef96cafcc8a85e55aa1d9c4ad88543bf9dd86c9d Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 02:02:14 +0200 Subject: [PATCH] feat: IPv6 support and multi-IP per node (closes #60) - maskIp handles IPv6 addresses (masks second and last group) - maskIp handles comma-separated IP strings (masks each address) - Add splitIps() helper to parse comma-separated IP field - Add primaryIp() helper used by status checker (first IP wins) - BaseNode renders each IP on its own line when comma-separated - NodeModal placeholder shows comma-separated example - Backend status_checker uses only first IP for connectivity checks - Expand maskIp test suite: IPv6, comma-separated, splitIps, primaryIp --- backend/app/services/status_checker.py | 4 +- .../canvas/__tests__/BaseNode.test.tsx | 1 + .../src/components/canvas/nodes/BaseNode.tsx | 11 ++-- frontend/src/components/modals/NodeModal.tsx | 4 +- .../modals/__tests__/NodeModal.test.tsx | 4 +- frontend/src/utils/__tests__/maskIp.test.ts | 59 ++++++++++++++++++- frontend/src/utils/maskIp.ts | 47 +++++++++++++-- 7 files changed, 111 insertions(+), 19 deletions(-) diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py index 85b939e..7f150e8 100644 --- a/backend/app/services/status_checker.py +++ b/backend/app/services/status_checker.py @@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d if check_method == "none": return {"status": "online", "response_time_ms": None} - host = target or ip + # Use only the first IP when the field contains comma-separated addresses + raw_ip = ip.split(",")[0].strip() if ip else None + host = target or raw_ip if not host: return {"status": "unknown", "response_time_ms": None} diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 71064df..3ffb576 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -48,6 +48,7 @@ vi.mock('@/utils/nodeIcons', () => ({ vi.mock('@/utils/maskIp', () => ({ maskIp: (ip: string) => ip, + splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [], })) vi.mock('@/utils/propertyIcons', () => ({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 8b95b7e..4dae035 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -8,7 +8,7 @@ import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' -import { maskIp } from '@/utils/maskIp' +import { maskIp, splitIps } from '@/utils/maskIp' import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils' interface BaseNodeProps extends NodeProps> { @@ -98,15 +98,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: > {data.label} - {data.ip && ( + {data.ip && splitIps(data.ip).map((ip) => (
- {hideIp ? maskIp(data.ip) : data.ip} + {hideIp ? maskIp(ip) : ip}
- )} + ))} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index c39f078..5ed1070 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -209,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* IP */}
- + set('ip', e.target.value)} - placeholder="192.168.1.x" + placeholder="192.168.1.x, 2001:db8::1" className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" />
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 4b24085..ffc0c2b 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -72,7 +72,7 @@ describe('NodeModal', () => { renderModal({ initial: BASE }) expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server') expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan') - expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10') + expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10') }) // ── Cancel ──────────────────────────────────────────────────────────── @@ -121,7 +121,7 @@ describe('NodeModal', () => { it('submits updated hostname, IP and notes', () => { const { onSubmit } = renderModal({ initial: BASE }) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } }) - fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } }) + fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } }) fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } }) fireEvent.click(screen.getByRole('button', { name: 'Add' })) const data = onSubmit.mock.calls[0][0] as Partial diff --git a/frontend/src/utils/__tests__/maskIp.test.ts b/frontend/src/utils/__tests__/maskIp.test.ts index 1f18cb5..d5f0a33 100644 --- a/frontend/src/utils/__tests__/maskIp.test.ts +++ b/frontend/src/utils/__tests__/maskIp.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest' -import { maskIp } from '../maskIp' +import { maskIp, splitIps, primaryIp } from '../maskIp' describe('maskIp', () => { + // IPv4 it('masks last two octets of a standard IPv4', () => { expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX') }) @@ -11,9 +12,61 @@ describe('maskIp', () => { expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX') }) - it('passes through non-IPv4 strings unchanged', () => { + // IPv6 + it('masks second group and last group of an IPv6 address', () => { + expect(maskIp('2001:db8::1')).toBe('2001:XX::XX') + }) + + it('masks a full IPv6 address', () => { + expect(maskIp('fe80:0000:0000:0000:0202:b3ff:fe1e:8329')).toBe('fe80:XX:0000:0000:0202:b3ff:fe1e:XX') + }) + + it('masks loopback IPv6', () => { + // ::1 splits into ['', '', '1'] — groups[1] and last are masked + expect(maskIp('::1')).toBe(':XX:XX') + }) + + // Comma-separated + it('masks all IPs in a comma-separated string', () => { + expect(maskIp('192.168.1.1, 2001:db8::1')).toBe('192.168.XX.XX, 2001:XX::XX') + }) + + it('handles comma-separated without spaces', () => { + expect(maskIp('10.0.0.1,10.0.0.2')).toBe('10.0.XX.XX, 10.0.XX.XX') + }) + + // Edge cases + it('passes through non-IP strings unchanged', () => { expect(maskIp('hostname')).toBe('hostname') - expect(maskIp('fe80::1')).toBe('fe80::1') expect(maskIp('')).toBe('') }) }) + +describe('splitIps', () => { + it('returns array of trimmed IPs', () => { + expect(splitIps('192.168.1.1, 2001:db8::1')).toEqual(['192.168.1.1', '2001:db8::1']) + }) + + it('returns single-element array for single IP', () => { + expect(splitIps('10.0.0.1')).toEqual(['10.0.0.1']) + }) + + it('returns empty array for empty string', () => { + expect(splitIps('')).toEqual([]) + expect(splitIps(' ')).toEqual([]) + }) +}) + +describe('primaryIp', () => { + it('returns first IP from comma-separated string', () => { + expect(primaryIp('192.168.1.1, 2001:db8::1')).toBe('192.168.1.1') + }) + + it('returns the only IP when single', () => { + expect(primaryIp('10.0.0.1')).toBe('10.0.0.1') + }) + + it('returns empty string for empty input', () => { + expect(primaryIp('')).toBe('') + }) +}) diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index 31b8fe9..dec2969 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -1,12 +1,47 @@ /** - * Mask the last two octets of an IPv4 address. - * e.g. "192.168.1.115" → "192.168.XX.XX" - * Non-IPv4 strings are returned unchanged. + * Mask a single IP address: + * - IPv4 "192.168.1.115" → "192.168.XX.XX" + * - IPv6 "2001:db8::1" → "2001:XX::XX" + * - Other strings returned unchanged. + */ +function maskSingle(ip: string): string { + const trimmed = ip.trim() + if (/^[\da-fA-F:]+$/.test(trimmed) && trimmed.includes(':')) { + const groups = trimmed.split(':') + if (groups.length >= 2) { + groups[1] = 'XX' + groups[groups.length - 1] = 'XX' + return groups.join(':') + } + } + const parts = trimmed.split('.') + if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX` + return trimmed +} + +/** + * Mask all IPs in a comma-separated string. + * e.g. "192.168.1.1, 2001:db8::1" → "192.168.XX.XX, 2001:XX::XX" */ export function maskIp(ip: string): string { - const parts = ip.split('.') - if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX` - return ip + if (!ip) return ip + return ip.split(',').map(maskSingle).join(', ') +} + +/** + * Split a comma-separated IP string into an array of trimmed values. + * Empty string returns []. + */ +export function splitIps(ip: string): string[] { + if (!ip?.trim()) return [] + return ip.split(',').map((s) => s.trim()).filter(Boolean) +} + +/** + * Return the first IP from a comma-separated string (used for status checks). + */ +export function primaryIp(ip: string): string { + return splitIps(ip)[0] ?? '' } export function primaryIp(ip: string): string {