diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index ae5591f..10c6107 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({ })) vi.mock('@/stores/canvasStore', () => ({ - useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), + useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ + hideIp: false, + }), })) vi.mock('@/utils/themes', () => ({ @@ -49,6 +51,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) : [], + primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '', })) vi.mock('@/utils/propertyIcons', () => ({ @@ -61,7 +64,9 @@ vi.mock('@/utils/handleUtils', () => ({ clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, })) -beforeEach(() => { mockZoom = 1 }) +beforeEach(() => { + mockZoom = 1 +}) function makeNode(data: Partial): Node { return { @@ -169,6 +174,49 @@ describe('BaseNode — properties rendering', () => { }) }) +describe('BaseNode — services visibility toggle', () => { + it('does not render service toggle button on the node', () => { + renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] }) + expect(screen.queryByTitle('Show services')).toBeNull() + }) + + it('renders service rows when services are toggled on', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [ + { service_name: 'nginx', port: 80, protocol: 'tcp' }, + { service_name: 'ssh', port: 22, protocol: 'tcp' }, + ], + }) + + expect(screen.getByText('nginx')).toBeDefined() + expect(screen.getByText('80')).toBeDefined() + expect(screen.getByText('ssh')).toBeDefined() + }) + + it('renders clickable service links for web services', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], + }) + + const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement + expect(link.getAttribute('href')).toBe('http://192.168.1.10:80') + }) + + it('keeps non-web services as non-clickable rows', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }], + }) + + expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull() + }) +}) + describe('BaseNode — legacy hardware fallback', () => { it('renders legacy hardware when properties is undefined and show_hardware is true', () => { renderBaseNode({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index c239452..177e4cb 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,6 +1,6 @@ import { createElement, useEffect, useMemo } from 'react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' -import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' +import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' @@ -8,8 +8,9 @@ import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' -import { maskIp, splitIps } from '@/utils/maskIp' +import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' +import { getServiceUrl } from '@/utils/serviceUrl' interface BaseNodeProps extends NodeProps> { icon: LucideIcon @@ -35,6 +36,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const colors = resolveNodeColors(data, activeTheme) const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' + const services = data.services ?? [] + const showServices = data.custom_colors?.show_services === true + const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname // Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes const visibleProperties = data.properties?.filter((p) => p.visible) ?? null @@ -117,6 +121,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: ))} + {/* Properties section (new system) */} @@ -138,6 +143,48 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )} + {showServices && services.length > 0 && ( + <> +
+
+ {services.map((svc, idx) => { + const url = getServiceUrl(svc, serviceHost) + const row = ( +
+ {svc.service_name} + + {svc.port} + {url && } + +
+ ) + + if (!url) return
{row}
+ + return ( + e.stopPropagation()} + > + {row} + + ) + })} +
+ + )} + {/* Legacy hardware section — fallback for nodes not yet migrated */} {showLegacyHardware && ( <> diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index c1666ba..13980d8 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -104,7 +104,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {NODE_TYPE_GROUPS.map((group, i) => ( - {i > 0 && } + {i > 0 && } {group.label} @@ -303,11 +303,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' +
+ )} + {/* Appearance */}
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 147f14d..e2a3c20 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -273,12 +273,33 @@ describe('NodeModal', () => { it('toggles container_mode on click', () => { const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } }) - fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByRole('switch', { name: 'Container Mode' })) fireEvent.click(screen.getByRole('button', { name: 'Add' })) expect((onSubmit.mock.calls[0][0] as Partial).container_mode).toBe(false) }) - // ── Parent container ────────────────────────────────────────────────── + // ── Show services toggle (modal-only) ─────────────────────────────── + + it('shows Show Services toggle for regular nodes', () => { + renderModal({ initial: BASE }) + expect(screen.getByText('Show Services')).toBeDefined() + expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined() + }) + + it('hides Show Services toggle for groupRect', () => { + renderModal({ initial: { ...BASE, type: 'groupRect' } }) + expect(screen.queryByText('Show Services')).toBeNull() + }) + + it('submits custom_colors.show_services=true when toggled on', () => { + const { onSubmit } = renderModal({ initial: BASE }) + fireEvent.click(screen.getByRole('switch', { name: 'Show Services' })) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + const data = onSubmit.mock.calls[0][0] as Partial + expect(data.custom_colors?.show_services).toBe(true) + }) + + // ── Parent Proxmox (vm / lxc only) ─────────────────────────────────── const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const const parentContainerHiddenTypes = ['groupRect', 'group'] as const diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 76291a8..59f687e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -82,6 +82,7 @@ export interface NodeData extends Record { border?: string background?: string icon?: string + show_services?: boolean // Group rectangle extras (type === 'groupRect') text_color?: string text_position?: TextPosition