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:
@@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
if check_method == "none":
|
if check_method == "none":
|
||||||
return {"status": "online", "response_time_ms": 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:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ vi.mock('@/utils/nodeIcons', () => ({
|
|||||||
|
|
||||||
vi.mock('@/utils/maskIp', () => ({
|
vi.mock('@/utils/maskIp', () => ({
|
||||||
maskIp: (ip: string) => ip,
|
maskIp: (ip: string) => ip,
|
||||||
|
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/propertyIcons', () => ({
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
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'
|
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
@@ -98,15 +98,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<div
|
<div
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[10px] truncate"
|
className="font-mono text-[10px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
title={data.ip}
|
title={ip}
|
||||||
>
|
>
|
||||||
{hideIp ? maskIp(data.ip) : data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -209,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => 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"
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
|
|||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
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('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 ────────────────────────────────────────────────────────────
|
// ── Cancel ────────────────────────────────────────────────────────────
|
||||||
@@ -121,7 +121,7 @@ describe('NodeModal', () => {
|
|||||||
it('submits updated hostname, IP and notes', () => {
|
it('submits updated hostname, IP and notes', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
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.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { maskIp } from '../maskIp'
|
import { maskIp, splitIps, primaryIp } from '../maskIp'
|
||||||
|
|
||||||
describe('maskIp', () => {
|
describe('maskIp', () => {
|
||||||
|
// IPv4
|
||||||
it('masks last two octets of a standard IPv4', () => {
|
it('masks last two octets of a standard IPv4', () => {
|
||||||
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
|
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')
|
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('hostname')).toBe('hostname')
|
||||||
expect(maskIp('fe80::1')).toBe('fe80::1')
|
|
||||||
expect(maskIp('')).toBe('')
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Mask the last two octets of an IPv4 address.
|
* Mask a single IP address:
|
||||||
* e.g. "192.168.1.115" → "192.168.XX.XX"
|
* - IPv4 "192.168.1.115" → "192.168.XX.XX"
|
||||||
* Non-IPv4 strings are returned unchanged.
|
* - 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 {
|
export function maskIp(ip: string): string {
|
||||||
const parts = ip.split('.')
|
if (!ip) return ip
|
||||||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
|
return ip.split(',').map(maskSingle).join(', ')
|
||||||
return ip
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export function primaryIp(ip: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user