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
+95 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
from app.core.scheduler import (
_run_service_checks,
_run_status_checks,
set_service_checks_enabled,
start_scheduler,
stop_scheduler,
)
from app.db.database import Base
from app.db.models import Node
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once()
# ---------------------------------------------------------------------------
# Service checks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_service_checks_disabled_does_nothing(mem_db):
async with mem_db() as session:
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = False
await _run_service_checks()
mock_cs.assert_not_called()
@pytest.mark.asyncio
async def test_run_service_checks_broadcasts_per_node(mem_db):
async with mem_db() as session:
node = _make_node(
ip="10.0.0.5",
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
)
session.add(node)
await session.commit()
node_id = node.id
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_bcast.assert_awaited_once()
_, kwargs = mock_bcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["services"] == statuses
@pytest.mark.asyncio
async def test_run_service_checks_skips_nodes_without_services(mem_db):
async with mem_db() as session:
session.add(_make_node(ip="10.0.0.6", services=[]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_cs.assert_not_called()
def test_set_service_checks_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.service_check_interval = 300
# Enable: no existing job -> add
mock_sched.get_job.return_value = None
set_service_checks_enabled(True)
mock_sched.add_job.assert_called_once()
# Disable: existing job -> remove
mock_sched.get_job.return_value = MagicMock()
set_service_checks_enabled(False)
mock_sched.remove_job.assert_called_once_with("service_checks")
def test_start_scheduler_adds_service_job_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
assert "service_checks" in job_ids
+39
View File
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
data = res.json()
assert "service_check_enabled" in data
assert "service_check_interval" in data
assert isinstance(data["service_check_enabled"], bool)
assert isinstance(data["service_check_interval"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={
"interval_seconds": 60,
"service_check_enabled": True,
"service_check_interval": 600,
},
headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["service_check_enabled"] is True
assert body["service_check_interval"] == 600
@pytest.mark.asyncio
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
headers=headers,
)
assert res.status_code == 422
+116 -1
View File
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.status_checker import _ping, _tcp_connect, check_node
from app.services.status_checker import (
_ping,
_tcp_connect,
check_node,
check_service,
check_services,
)
# --- check_node dispatcher ---
@@ -342,3 +348,112 @@ async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999)
assert result is False
# --- check_service ---
@pytest.mark.asyncio
async def test_check_service_no_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
@pytest.mark.asyncio
async def test_check_service_flag_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
@pytest.mark.asyncio
async def test_check_service_udp_is_unknown():
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_portless_non_web_is_unknown():
svc = {"protocol": "tcp", "service_name": "thing"}
assert await check_service(svc, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_web_uses_http_get():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
result = await check_service(svc, "10.0.0.1")
assert result == "online"
assert captured["url"] == "http://10.0.0.1:8080"
@pytest.mark.asyncio
async def test_check_service_https_port_uses_https_scheme():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "10.0.0.1")
assert captured["url"].startswith("https://")
@pytest.mark.asyncio
async def test_check_service_web_offline_when_http_fails():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
assert await check_service(svc, "10.0.0.1") == "offline"
@pytest.mark.asyncio
async def test_check_service_non_http_port_uses_tcp():
captured = {}
async def fake_tcp(host, port):
captured["host"] = host
captured["port"] = port
return True
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
with patch("app.services.status_checker._tcp_connect", side_effect=fake_tcp):
result = await check_service(svc, "10.0.0.1")
assert result == "online"
assert captured == {"host": "10.0.0.1", "port": 5432}
@pytest.mark.asyncio
async def test_check_service_ipv6_brackets_url_host():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "2001:db8::1")
assert captured["url"] == "http://[2001:db8::1]:80"
@pytest.mark.asyncio
async def test_check_services_returns_status_per_service():
services = [
{"port": 80, "protocol": "tcp", "service_name": "http"},
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
]
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True), \
patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock, return_value=False):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "offline"},
]
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []