bcc488993d
Adds optional live status checking per service (not just per node), requested as a follow-up to issue #196. Backend: - New check_service / check_services: HTTP(S) GET for web services, TCP connect otherwise; UDP and port-less non-web services stay 'unknown'. - New scheduler job 'service_checks', independent interval (default 300s), added/removed live via set_service_checks_enabled. - Settings gain service_check_enabled + service_check_interval (>=30s), persisted to scan_config.json. New WS message type 'service_status'. Frontend: - Live per-service status overlay in canvasStore (not persisted, so it never round-trips through canvas save), fed by the WS message. - DetailPanel + canvas node service rows: offline service turns red (#f85149), otherwise keeps its category colour. - SettingsModal: toggle + interval input (default 300s / 5 min). Off by default — no behaviour change until enabled. ha-relevant: yes
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { createElement, useEffect, useMemo } from 'react'
|
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
|
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
|
import type { NodeData } from '@/types'
|
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
|
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
|
import { NodeIcon } from '@/components/ui/NodeIcon'
|
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
|
import { useThemeStore } from '@/stores/themeStore'
|
|
import { THEMES } from '@/utils/themes'
|
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
|
|
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
|
icon: LucideIcon
|
|
}
|
|
|
|
function formatStorage(gb: number): string {
|
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
|
return `${gb} GB`
|
|
}
|
|
|
|
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
|
const updateNodeInternals = useUpdateNodeInternals()
|
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
|
|
|
const { zoom } = useViewport()
|
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
|
|
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
|
const theme = THEMES[activeTheme]
|
|
|
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
|
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
|
|
const showLegacyHardware = !data.properties && data.show_hardware &&
|
|
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
|
|
|
return (
|
|
<div
|
|
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
|
style={{
|
|
background: colors.background,
|
|
borderColor: colors.border,
|
|
borderWidth,
|
|
boxShadow: isOnline && selected
|
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
|
: isOnline
|
|
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
|
: selected
|
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
|
: 'none',
|
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
|
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
|
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
|
width: width ? '100%' : undefined,
|
|
height: height ? '100%' : undefined,
|
|
}}
|
|
>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={140}
|
|
minHeight={50}
|
|
lineStyle={{ borderColor: 'transparent' }}
|
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Top}
|
|
id="top"
|
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
/>
|
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
|
|
|
{/* Status dot — absolute to avoid affecting node auto-width */}
|
|
<div
|
|
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
|
style={{ backgroundColor: statusColor }}
|
|
title={data.status}
|
|
/>
|
|
|
|
{/* Main row */}
|
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
|
{/* Icon */}
|
|
<div
|
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
|
style={{
|
|
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
|
background: theme.colors.nodeIconBackground,
|
|
}}
|
|
>
|
|
{isBrandIconKey(data.custom_icon)
|
|
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
|
|
: createElement(resolvedIcon, { size: 15 })}
|
|
</div>
|
|
|
|
{/* Label + IP */}
|
|
<div className="flex flex-col min-w-0">
|
|
<div
|
|
className="text-xs font-medium leading-tight truncate"
|
|
style={{ color: theme.colors.nodeLabelColor }}
|
|
title={data.label}
|
|
>
|
|
{data.label}
|
|
</div>
|
|
{data.ip && splitIps(data.ip).map((ip) => (
|
|
<div
|
|
key={ip}
|
|
className="font-mono text-[10px] truncate"
|
|
style={{ color: theme.colors.nodeSubtextColor }}
|
|
title={ip}
|
|
>
|
|
{hideIp ? maskIp(ip) : ip}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Properties section (new system) */}
|
|
{visibleProperties && visibleProperties.length > 0 && (
|
|
<>
|
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
|
{visibleProperties.map((prop) => {
|
|
const Icon = resolvePropertyIcon(prop.icon)
|
|
return (
|
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
|
{Icon && <Icon size={9} className="shrink-0" />}
|
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{showServices && services.length > 0 && (
|
|
<>
|
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
|
{services.map((svc, idx) => {
|
|
const url = getServiceUrl(svc, serviceHost)
|
|
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
|
const row = (
|
|
<div
|
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
|
style={{
|
|
background: theme.colors.nodeIconBackground,
|
|
color: theme.colors.nodeSubtextColor,
|
|
}}
|
|
>
|
|
|
|
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
|
{/* LEFT: service name */}
|
|
<span
|
|
className="font-medium truncate"
|
|
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
|
title={svc.service_name}
|
|
>
|
|
{svc.service_name}
|
|
</span>
|
|
|
|
{/* RIGHT: path + port */}
|
|
<div className="flex items-center gap-2 shrink-0 min-w-0">
|
|
{svc.path && (
|
|
<span
|
|
className="truncate text-[#8b949e] text-right max-w-[80px]"
|
|
title={svc.path}
|
|
>
|
|
{svc.path}
|
|
</span>
|
|
)}
|
|
|
|
<span className="font-mono opacity-80 flex items-center gap-1">
|
|
<span>{svc.port}</span>
|
|
<ExternalLink
|
|
size={9}
|
|
className={`shrink-0 ${url ? '' : 'opacity-0'}`}
|
|
/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (!url) return <div key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}>{row}</div>
|
|
|
|
return (
|
|
<a
|
|
key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block hover:opacity-85 transition-opacity"
|
|
title={url}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{row}
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
|
{showLegacyHardware && (
|
|
<>
|
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
|
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
|
{(data.cpu_model || data.cpu_count != null) && (
|
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
|
<Cpu size={9} className="shrink-0" />
|
|
{data.cpu_model && (
|
|
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
|
|
)}
|
|
{data.cpu_count != null && (
|
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{(data.ram_gb != null || data.disk_gb != null) && (
|
|
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
|
{data.ram_gb != null && (
|
|
<span className="flex items-center gap-0.5">
|
|
<MemoryStick size={9} className="shrink-0" />
|
|
{formatStorage(data.ram_gb)}
|
|
</span>
|
|
)}
|
|
{data.disk_gb != null && (
|
|
<span className="flex items-center gap-0.5">
|
|
<HardDrive size={9} className="shrink-0" />
|
|
{formatStorage(data.disk_gb)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
|
const sourceId = bottomHandleId(idx)
|
|
const targetId = `${sourceId}-t`
|
|
return (
|
|
<span key={sourceId}>
|
|
{data.show_port_numbers && (
|
|
<span
|
|
className="absolute font-mono leading-none pointer-events-none select-none"
|
|
style={{
|
|
left: `${leftPct}%`,
|
|
bottom: 3,
|
|
transform: 'translateX(-50%)',
|
|
fontSize: 7,
|
|
color: theme.colors.nodeSubtextColor,
|
|
}}
|
|
>
|
|
{idx + 1}
|
|
</span>
|
|
)}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id={sourceId}
|
|
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Bottom}
|
|
id={targetId}
|
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
|
/>
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|