diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index ae5591f..a1fe392 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -49,6 +49,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', () => ({ @@ -169,6 +170,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..2a3babd 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 @@ -138,6 +142,74 @@ 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 = ( +
+ +
+ {/* LEFT: service name */} + + {svc.service_name} + + + {/* RIGHT: path + port */} +
+ {svc.path && ( + + {svc.path} + + )} + + + {svc.port} + + +
+
+
+ ) + + 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..f04abc7 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -61,6 +61,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) const [labelError, setLabelError] = useState(false) + const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors }) + const showServicesEnabled = form.custom_colors?.show_services === true + const hasAppearanceOverrides = Boolean( + form.custom_colors?.border + || form.custom_colors?.background + || form.custom_colors?.icon + ) const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) @@ -298,21 +305,52 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
- Allow other nodes to nest inside this node + + Allow other nodes to nest inside this node + +
+ + +
+ )} + + {/* Service visibility */} + {form.type !== 'groupRect' && form.type !== 'group' && ( +
+
+ + Display discovered services on the node card
@@ -322,10 +360,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
- {form.custom_colors && ( + {hasAppearanceOverrides && (
- {!form.custom_colors && ( -

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

- )} +
+ {!hasAppearanceOverrides && ( +

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

+ )} +
{/* Bottom connection points (not for group containers) */} diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 147f14d..ef32c0d 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -273,12 +273,45 @@ 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) + }) + + it('keeps default colors hint visible when Show Services is toggled on', () => { + renderModal({ initial: BASE }) + fireEvent.click(screen.getByRole('switch', { name: 'Show Services' })) + expect(screen.getByText(/Using default colors for/)).toBeDefined() + }) + + it('does not show Appearance reset when only Show Services is set', () => { + renderModal({ initial: BASE }) + fireEvent.click(screen.getByRole('switch', { name: 'Show Services' })) + expect(screen.queryByText('Reset to defaults')).toBeNull() + }) + + // ── 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/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index b53a8ad..96d8cd4 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -2,7 +2,7 @@ import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' + import { useCanvasStore } from '@/stores/canvasStore' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' @@ -662,82 +662,80 @@ const CATEGORY_COLORS: Record = { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' - const hasPort = svc.port != null - const portLabel = hasPort ? String(svc.port) : '' const pathLabel = svc.path?.trim() ? svc.path.trim() : '' return (
- - {url ? ( - e.stopPropagation()} - > - {svc.service_name} - - ) : ( - - {svc.service_name} - - )} -
- {pathLabel && ( - - - - - {pathLabel} - - - {pathLabel} - - - )} - {hasPort && ( - {portLabel}/{svc.protocol} - )} +
+ + {url ? ( e.stopPropagation()} + > + {svc.service_name} + + ) : ( + + {svc.service_name} + + )} + + {pathLabel && ( + + {pathLabel} + + )} +
+ +
+ {svc.port != null && ( + + {svc.port}/{svc.protocol} + + )} + + {url ? ( + e.stopPropagation()} > ) : ( - + )} + +