Merge pull request #136 from findthelorax/feat/multiple-ips
feat: multiple ips in detail panel
This commit is contained in:
Generated
+63
@@ -2520,6 +2520,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2534,6 +2537,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2548,6 +2554,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2562,6 +2571,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2576,6 +2588,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2590,6 +2605,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2604,6 +2622,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2618,6 +2639,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2632,6 +2656,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2646,6 +2673,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2660,6 +2690,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2674,6 +2707,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2688,6 +2724,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2936,6 +2975,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2953,6 +2995,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2970,6 +3015,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2987,6 +3035,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6884,6 +6935,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6905,6 +6959,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6926,6 +6983,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6947,6 +7007,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
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 { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const { data } = node
|
const { data } = node
|
||||||
const services = data.services ?? []
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
const host = data.ip ?? data.hostname
|
const ipAddresses = data.ip ? splitIps(data.ip) : []
|
||||||
|
const host = ipAddresses[0] ?? data.hostname
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.ip && (
|
{ipAddresses.length > 0 && (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-start">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
{ipAddresses.map((ip, index) => (
|
||||||
|
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={`http://${ip}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||||||
|
title={ip}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
|
|||||||
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
|
|||||||
it('displays full comma-separated IP string as link text', () => {
|
it('displays full comma-separated IP string as link text', () => {
|
||||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
|
||||||
|
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(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
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(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ describe('splitIps', () => {
|
|||||||
expect(splitIps('')).toEqual([])
|
expect(splitIps('')).toEqual([])
|
||||||
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', () => {
|
describe('primaryIp', () => {
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
|
|||||||
*/
|
*/
|
||||||
export function maskIp(ip: string): string {
|
export function maskIp(ip: string): string {
|
||||||
if (!ip) return ip
|
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.
|
* Split an IP string into trimmed values. Accepts comma, semicolon, or newline
|
||||||
* Empty string returns [].
|
* delimiters (or any combination). Duplicates removed, empty entries dropped.
|
||||||
*/
|
*/
|
||||||
export function splitIps(ip: string): string[] {
|
export function splitIps(ip: string): string[] {
|
||||||
if (!ip?.trim()) return []
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user