feat: per-service status checks with offline colouring

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
This commit is contained in:
Pouzor
2026-06-10 02:13:15 +02:00
parent aa0a97c245
commit bcc488993d
21 changed files with 615 additions and 29 deletions
@@ -31,9 +31,36 @@ describe('canvasStore', () => {
past: [],
future: [],
clipboard: { nodes: [], edges: [] },
serviceStatuses: {},
})
})
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [
{ port: 80, protocol: 'tcp', status: 'offline' },
{ port: 443, protocol: 'tcp', status: 'online' },
])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
})
it('setServiceStatuses merges without dropping other nodes', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
})
it('does not mark canvas unsaved on a service status update', () => {
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1')
+19 -1
View File
@@ -9,7 +9,7 @@ import {
applyEdgeChanges,
addEdge,
} from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
@@ -21,6 +21,10 @@ type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
/** Resolve a node's effective parent id from either the RF field or domain data. */
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
interface CanvasState {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
@@ -28,6 +32,8 @@ interface CanvasState {
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus>
// History
past: HistoryEntry[]
@@ -68,6 +74,7 @@ interface CanvasState {
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
hideIp: boolean
toggleHideIp: () => void
setHideIp: (value: boolean) => void
@@ -86,6 +93,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
editingTextId: null,
hideIp: readHideIp(),
scanEventTs: 0,
serviceStatuses: {},
fitViewPending: false,
past: [],
@@ -581,6 +589,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
setServiceStatuses: (nodeId, statuses) =>
set((state) => {
// Live overlay only — never touches node data, so it stays out of saves.
const next = { ...state.serviceStatuses }
for (const s of statuses) {
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
}
return { serviceStatuses: next }
}),
toggleHideIp: () => set((s) => {
const hideIp = !s.hideIp
writeHideIp(hideIp)