diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx
index 1c9d82b..2deff7e 100644
--- a/frontend/src/components/panels/DetailPanel.tsx
+++ b/frontend/src/components/panels/DetailPanel.tsx
@@ -5,6 +5,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 { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react'
@@ -202,7 +203,14 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)}
- {data.ip && }
+ {data.ip && (
+
+ )}
{data.mac && }
{data.os && }
{data.check_method && }
diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
index 3ecad14..07066bd 100644
--- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
+++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx
@@ -385,4 +385,34 @@ describe('DetailPanel', () => {
expect(screen.getByText('nginx')).toBeDefined()
})
})
+
+ describe('IP Address — clickable link', () => {
+ it('renders a link for a single IP', () => {
+ setupStore({ ip: '192.168.1.10' })
+ render()
+ const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
+ expect(link).toBeDefined()
+ expect(link.getAttribute('href')).toBe('http://192.168.1.10')
+ expect(link.getAttribute('target')).toBe('_blank')
+ })
+
+ it('renders no IP link when ip is absent', () => {
+ setupStore({ ip: undefined })
+ render()
+ expect(screen.queryByText('IP Address')).toBeNull()
+ })
+
+ it('uses primary IP as href for comma-separated IPs', () => {
+ setupStore({ ip: '192.168.1.10, 192.168.1.11' })
+ render()
+ const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
+ expect(link.getAttribute('href')).toBe('http://192.168.1.10')
+ })
+
+ it('displays full comma-separated IP string as link text', () => {
+ setupStore({ ip: '192.168.1.10, 192.168.1.11' })
+ render()
+ expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
+ })
+ })
})
diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts
index 7c0e8c9..31b8fe9 100644
--- a/frontend/src/utils/maskIp.ts
+++ b/frontend/src/utils/maskIp.ts
@@ -8,3 +8,8 @@ export function maskIp(ip: string): string {
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
return ip
}
+
+export function primaryIp(ip: string): string {
+ if (!ip?.trim()) return ''
+ return ip.split(',')[0].trim()
+}