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:
@@ -1,20 +1,27 @@
|
||||
"""App-level settings (status checker interval, etc.)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
interval_seconds: int
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = Field(default=300, ge=30)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
||||
return AppSettings(
|
||||
interval_seconds=settings.status_checker_interval,
|
||||
service_check_enabled=settings.service_check_enabled,
|
||||
service_check_interval=settings.service_check_interval,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AppSettings)
|
||||
@@ -23,7 +30,13 @@ async def update_settings(
|
||||
) -> AppSettings:
|
||||
try:
|
||||
settings.status_checker_interval = payload.interval_seconds
|
||||
settings.service_check_enabled = payload.service_check_enabled
|
||||
settings.service_check_interval = payload.service_check_interval
|
||||
settings.save_overrides()
|
||||
# Apply the service-check schedule live.
|
||||
set_service_checks_enabled(payload.service_check_enabled)
|
||||
if payload.service_check_enabled:
|
||||
reschedule_service_checks(payload.service_check_interval)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@@ -54,6 +54,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_service_status(node_id: str, services: list[dict], checked_at: str) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "service_status",
|
||||
"node_id": node_id,
|
||||
"services": services,
|
||||
"checked_at": checked_at,
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "scan_device_found",
|
||||
|
||||
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
# Per-service status checker (independent of node checks). Off by default.
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = 300
|
||||
|
||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||
# Used by the MCP server to authenticate against the backend without a user password.
|
||||
# Leave empty to disable MCP service key auth.
|
||||
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
|
||||
self.scanner_ranges = data["scanner_ranges"]
|
||||
if "status_checker_interval" in data:
|
||||
self.status_checker_interval = int(data["status_checker_interval"])
|
||||
if "service_check_enabled" in data:
|
||||
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||
if "service_check_interval" in data:
|
||||
self.service_check_interval = int(data["service_check_interval"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
|
||||
self._override_path().write_text(json.dumps({
|
||||
"scanner_ranges": self.scanner_ranges,
|
||||
"status_checker_interval": self.status_checker_interval,
|
||||
"service_check_enabled": self.service_check_enabled,
|
||||
"service_check_interval": self.service_check_interval,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal
|
||||
from app.db.models import Node
|
||||
from app.services.status_checker import check_node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
||||
])
|
||||
|
||||
|
||||
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||
"""Pick the address to probe services on: first IP, else hostname."""
|
||||
if ip:
|
||||
first = ip.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return hostname or None
|
||||
|
||||
|
||||
async def _run_service_checks() -> None:
|
||||
"""Check every service of every node and broadcast per-service results."""
|
||||
if not settings.service_check_enabled:
|
||||
return
|
||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
checkable = [
|
||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||
for n in nodes
|
||||
if n.services
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||
except Exception as exc:
|
||||
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||
|
||||
|
||||
def _add_service_check_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_service_checks,
|
||||
"interval",
|
||||
seconds=settings.service_check_interval,
|
||||
id="service_checks",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
if settings.service_check_enabled:
|
||||
_add_service_check_job()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 30:
|
||||
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("service_checks"):
|
||||
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_service_checks_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the service-check job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("service_checks")
|
||||
if enabled and not job:
|
||||
_add_service_check_job()
|
||||
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("service_checks")
|
||||
logger.info("Service checks disabled")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
@@ -118,3 +118,74 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
||||
return True
|
||||
except (TimeoutError, OSError, socket.gaierror):
|
||||
return False
|
||||
|
||||
|
||||
# --- Per-service status checks ---
|
||||
|
||||
# Ports that are definitely not HTTP/web — mirror of frontend serviceUrl.ts.
|
||||
# SSH (22) is handled as a plain TCP check, so it is intentionally absent here.
|
||||
_NON_HTTP_PORTS = frozenset({
|
||||
21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||
})
|
||||
_HTTPS_PORTS = frozenset({443, 8443})
|
||||
|
||||
|
||||
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||
"""Bracket bare IPv6 literals for use in a URL."""
|
||||
return f"[{host}]" if _is_ipv6(host) else host
|
||||
|
||||
|
||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||
|
||||
Web services get an HTTP(S) GET; everything else with a port gets a TCP
|
||||
connect. UDP services and port-less non-web services are 'unknown' so they
|
||||
keep their category colour rather than flashing red.
|
||||
"""
|
||||
if not host or host.startswith("-"):
|
||||
return "unknown"
|
||||
if str(svc.get("protocol", "")).lower() == "udp":
|
||||
return "unknown"
|
||||
|
||||
port = svc.get("port")
|
||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||
|
||||
try:
|
||||
if port is not None and port in _NON_HTTP_PORTS:
|
||||
return "online" if await _tcp_connect(host, port) else "offline"
|
||||
|
||||
name = str(svc.get("service_name", "")).lower()
|
||||
is_web = port is None or port not in _NON_HTTP_PORTS
|
||||
if is_web and (port is not None or "http" in name):
|
||||
scheme = "https" if (
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http"
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
return "online" if await _http_get(url, verify=False) else "offline"
|
||||
|
||||
if port is not None:
|
||||
return "online" if await _tcp_connect(host, port) else "offline"
|
||||
|
||||
return "unknown"
|
||||
except Exception as exc:
|
||||
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||
return "offline"
|
||||
|
||||
|
||||
async def check_services(
|
||||
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Check every service against host concurrently (bounded).
|
||||
|
||||
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||
"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
status = await check_service(svc, host)
|
||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||
|
||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", []) == []
|
||||
|
||||
Reference in New Issue
Block a user