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
@@ -20,6 +20,8 @@ interface SettingsModalProps {
export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
const [serviceInterval, setServiceInterval] = useState(300)
const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const hideIp = useCanvasStore((s) => s.hideIp)
@@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
useEffect(() => {
if (!open || STANDALONE) return
settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds))
.then((res) => {
setIntervalValue(res.data.interval_seconds)
setServiceCheckEnabled(res.data.service_check_enabled)
setServiceInterval(res.data.service_check_interval)
})
.catch(() => {/* use default */})
}, [open])
@@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
}
setSaving(true)
try {
await settingsApi.save({ interval_seconds: interval })
await settingsApi.save({
interval_seconds: interval,
service_check_enabled: serviceCheckEnabled,
service_check_interval: serviceInterval,
})
toast.success('Settings saved')
onClose()
} catch {
@@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
<span className="text-xs text-foreground">Check services individually</span>
<input
type="checkbox"
checked={serviceCheckEnabled}
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle per-service status checks"
/>
</label>
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={30}
max={3600}
value={serviceInterval}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
aria-label="Service check interval"
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Probes each service port. Offline services turn red. Default 300s (5 min).
</p>
</div>
</div>
)}
@@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore'
describe('SettingsModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
@@ -47,7 +47,7 @@ describe('SettingsModal', () => {
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
expect(onClose).toHaveBeenCalled()
})
@@ -76,6 +76,20 @@ describe('SettingsModal', () => {
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
})
it('loads and toggles the per-service check setting, saving its interval', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
render(<SettingsModal open onClose={vi.fn()} />)
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
expect(toggle.checked).toBe(true)
expect(await screen.findByDisplayValue('600')).toBeDefined()
fireEvent.click(toggle) // disable
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
})
})
it('calls onClose on Cancel', async () => {
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)