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:
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
|
||||
|
||||
const mockUpdateNode = vi.fn()
|
||||
const mockNotifyScanDeviceFound = vi.fn()
|
||||
const mockSetServiceStatuses = vi.fn()
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = []
|
||||
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
updateNode: mockUpdateNode,
|
||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||
setServiceStatuses: mockSetServiceStatuses,
|
||||
} as ReturnType<typeof useCanvasStore>)
|
||||
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
|
||||
vi.restoreAllMocks()
|
||||
mockUpdateNode.mockClear()
|
||||
mockNotifyScanDeviceFound.mockClear()
|
||||
mockSetServiceStatuses.mockClear()
|
||||
})
|
||||
|
||||
it('does not open WebSocket when not authenticated', () => {
|
||||
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes service_status messages to setServiceStatuses', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||
})
|
||||
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores malformed JSON without throwing', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { ServiceStatus } from '@/types'
|
||||
|
||||
interface ServiceStatusEntry {
|
||||
port?: number
|
||||
protocol?: string
|
||||
status: ServiceStatus
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
type?: string
|
||||
@@ -10,13 +17,14 @@ interface StatusMessage {
|
||||
response_time_ms?: number | null
|
||||
run_id?: string
|
||||
devices_found?: number
|
||||
services?: ServiceStatusEntry[]
|
||||
}
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
|
||||
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,6 +47,8 @@ export function useStatusPolling() {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
if (msg.type === 'scan_device_found') {
|
||||
notifyScanDeviceFound()
|
||||
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||
setServiceStatuses(msg.node_id, msg.services)
|
||||
} else if (msg.node_id && msg.status) {
|
||||
updateNode(msg.node_id, {
|
||||
status: msg.status,
|
||||
@@ -59,5 +69,5 @@ export function useStatusPolling() {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound])
|
||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user