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
This commit is contained in:
Pouzor
2026-04-19 02:02:14 +02:00
parent 6c9974b357
commit ef96cafcc8
7 changed files with 111 additions and 19 deletions
+56 -3
View File
@@ -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('')
})
})
+41 -6
View File
@@ -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 {