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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user