Merge pull request #198 from Pouzor/fix/status-ping-service-color

feat: per-service status checks + ping/service-color fixes (#196)
This commit is contained in:
Rémy
2026-06-10 11:13:06 +02:00
committed by GitHub
22 changed files with 824 additions and 42 deletions
+15 -2
View File
@@ -1,20 +1,27 @@
"""App-level settings (status checker interval, etc.).""" """App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException 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.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
router = APIRouter() router = APIRouter()
class AppSettings(BaseModel): class AppSettings(BaseModel):
interval_seconds: int interval_seconds: int
service_check_enabled: bool = False
service_check_interval: int = Field(default=300, ge=30)
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> 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) @router.post("", response_model=AppSettings)
@@ -23,7 +30,13 @@ async def update_settings(
) -> AppSettings: ) -> AppSettings:
try: try:
settings.status_checker_interval = payload.interval_seconds 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() 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 return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
+22 -2
View File
@@ -1,3 +1,4 @@
import contextlib
import json import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -10,6 +11,12 @@ router = APIRouter()
_connections: list[WebSocket] = [] _connections: list[WebSocket] = []
def _drop(websocket: WebSocket) -> None:
"""Remove a connection if still present — idempotent, never raises."""
with contextlib.suppress(ValueError):
_connections.remove(websocket)
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket) -> None: async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code # Accept first so we can send a close frame with a reason code
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
while True: while True:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
_connections.remove(websocket) pass
finally:
# Any error (disconnect or otherwise) must release the slot, else the
# dead socket lingers in the broadcast pool.
_drop(websocket)
async def _broadcast(payload: str) -> None: async def _broadcast(payload: str) -> None:
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
try: try:
await conn.send_text(payload) await conn.send_text(payload)
except Exception: except Exception:
_connections.remove(conn) _drop(conn)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None: async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
@@ -54,6 +65,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[str, object]], 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: async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
await _broadcast(json.dumps({ await _broadcast(json.dumps({
"type": "scan_device_found", "type": "scan_device_found",
+10
View File
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
# Status checker # Status checker
status_checker_interval: int = 60 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 # MCP service key — set MCP_SERVICE_KEY in .env
# Used by the MCP server to authenticate against the backend without a user password. # Used by the MCP server to authenticate against the backend without a user password.
# Leave empty to disable MCP service key auth. # Leave empty to disable MCP service key auth.
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
self.scanner_ranges = data["scanner_ranges"] self.scanner_ranges = data["scanner_ranges"]
if "status_checker_interval" in data: if "status_checker_interval" in data:
self.status_checker_interval = int(data["status_checker_interval"]) 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: except Exception:
pass pass
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
self._override_path().write_text(json.dumps({ self._override_path().write_text(json.dumps({
"scanner_ranges": self.scanner_ranges, "scanner_ranges": self.scanner_ranges,
"status_checker_interval": self.status_checker_interval, "status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
})) }))
+72 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from app.core.config import settings from app.core.config import settings
from app.db.database import AsyncSessionLocal from app.db.database import AsyncSessionLocal
from app.db.models import Node 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__) 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: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running: if scheduler.running:
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
if settings.service_check_enabled:
_add_service_check_job()
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) 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) 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: def stop_scheduler() -> None:
if scheduler.running: if scheduler.running:
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
+97 -7
View File
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
return {"status": "offline", "response_time_ms": None} return {"status": "offline", "response_time_ms": None}
def _is_ipv6(host: str) -> bool:
"""True if host is a literal IPv6 address (bracketed or bare)."""
try:
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
return True
except OSError:
return False
async def _ping(host: str) -> bool: async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS: # Send 2 probes with a ~2s timeout so a single dropped packet or a slow
# Linux: seconds (-W 1 = 1s) # device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms) #
# Windows: -w in ms (-w 1000 = 1s) # -W flag units differ by OS:
# Linux: seconds (-W 2 = 2s)
# macOS: milliseconds (-W 2000 = 2s)
# Windows: -w in ms (-w 2000 = 2s)
#
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
ipv6 = _is_ipv6(host)
if sys.platform == "win32": if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host] family = ["-6"] if ipv6 else ["-4"]
args = ["ping", *family, "-n", "2", "-w", "2000", host]
elif sys.platform == "darwin": elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host] args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
else: else:
args = ["ping", "-c", "1", "-W", "1", host] family = ["-6"] if ipv6 else []
args = ["ping", *family, "-c", "2", "-W", "2", host]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
return True return True
except (TimeoutError, OSError, socket.gaierror): except (TimeoutError, OSError, socket.gaierror):
return False return False
# --- Per-service status checks ---
# Ports that are not HTTP/web. These get NO status check — a service here stays
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
_NON_HTTP_PORTS = frozenset({
22, 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'.
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
so it keeps its category colour instead of flashing red. An open TCP socket
doesn't prove a non-web service is healthy, so we don't pretend it does.
"""
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
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
if port is not None and port in _NON_HTTP_PORTS:
return "unknown"
name = str(svc.get("service_name", "")).lower()
is_web = port is not None or "http" in name
if not is_web:
return "unknown"
try:
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"
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 []
+95 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 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.database import Base
from app.db.models import Node 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, \ with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched): patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45 mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
start_scheduler() start_scheduler()
_, kwargs = mock_sched.add_job.call_args _, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45 assert kwargs["seconds"] == 45
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
mock_sched.add_job.assert_called_once() mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once() mock_sched.start.assert_called_once()
mock_sched.shutdown.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): async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30}) res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401 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
+67 -1
View File
@@ -5,7 +5,13 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status from app.api.routes.status import (
_connections,
_drop,
broadcast_scan_update,
broadcast_service_status,
broadcast_status,
)
from app.main import app from app.main import app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
assert len(_connections) == 0 assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t") await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0) await broadcast_scan_update(run_id="r", devices_found=0)
# ---------------------------------------------------------------------------
# broadcast_service_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_service_status_payload():
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_service_status(
node_id="node-7",
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
checked_at="2024-01-01T00:00:00",
)
finally:
_drop(fake)
msg = json.loads(received[0])
assert msg["type"] == "service_status"
assert msg["node_id"] == "node-7"
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
# ---------------------------------------------------------------------------
# _drop — idempotent connection removal (regression for double-remove crash)
# ---------------------------------------------------------------------------
def test_drop_is_idempotent():
"""Dropping a connection twice must not raise (was a ValueError crash)."""
class FakeWS:
pass
fake = FakeWS()
_connections.append(fake)
_drop(fake)
_drop(fake) # second drop must be a no-op
assert fake not in _connections
@pytest.mark.asyncio
async def test_broadcast_dead_connection_dropped_once_safely():
"""A send failure removes the dead socket without a double-remove crash."""
class DeadWS:
async def send_text(self, _: str) -> None:
raise RuntimeError("disconnected")
dead = DeadWS()
_connections.append(dead)
await broadcast_status(node_id="n", status="online", checked_at="t")
# A second broadcast must not raise even though dead is already gone.
await broadcast_status(node_id="n", status="online", checked_at="t")
assert dead not in _connections
+198 -4
View File
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest 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 --- # --- check_node dispatcher ---
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
assert "-n" not in captured["args"] assert "-n" not in captured["args"]
# Linux: -W is in seconds; 1s is the intended timeout # 2 probes so a single dropped packet doesn't flap the node offline
c_idx = captured["args"].index("-c")
assert captured["args"][c_idx + 1] == "2"
# Linux: -W is in seconds; 2s is the intended timeout
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1" assert captured["args"][w_idx + 1] == "2"
# IPv4 target → no -6 flag
assert "-6" not in captured["args"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1000" assert captured["args"][w_idx + 1] == "2000"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"] assert "-c" not in captured["args"]
# --- _ping IPv6 support ---
@pytest.mark.asyncio
async def test_ping_ipv6_linux_uses_dash6():
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("fe80::1")
assert "-6" in captured["args"]
assert captured["args"][-1] == "fe80::1"
@pytest.mark.asyncio
async def test_ping_ipv6_macos_uses_ping6():
"""macOS ships a separate ping6 binary for IPv6 targets."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert captured["args"][0] == "ping6"
@pytest.mark.asyncio
async def test_ping_ipv6_windows_uses_dash6():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert "-6" in captured["args"]
def test_is_ipv6_detection():
from app.services.status_checker import _is_ipv6
assert _is_ipv6("fe80::1") is True
assert _is_ipv6("2001:db8::1") is True
assert _is_ipv6("[2001:db8::1]") is True
assert _is_ipv6("192.168.1.1") is False
assert _is_ipv6("example.local") is False
# --- check_node target validation --- # --- check_node target validation ---
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")): with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999) result = await _tcp_connect("192.168.1.1", 9999)
assert result is False 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_is_unknown():
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
mock_http.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ssh_port_22_is_unknown():
"""SSH (port 22) is never checked — keep it grey, not red/green."""
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
@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):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "unknown"},
]
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
+2 -2
View File
@@ -204,8 +204,8 @@ describe('api/client', () => {
it('settingsApi get/save', () => { it('settingsApi get/save', () => {
mod.settingsApi.get() mod.settingsApi.get()
expect(api.get).toHaveBeenCalledWith('/settings') expect(api.get).toHaveBeenCalledWith('/settings')
mod.settingsApi.save({ interval_seconds: 30 }) mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 }) expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
}) })
it('zigbeeApi.testConnection/importNetwork/importToPending', () => { it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
+8 -2
View File
@@ -90,9 +90,15 @@ export const scanApi = {
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
} }
export interface AppSettings {
interval_seconds: number
service_check_enabled: boolean
service_check_interval: number
}
export const settingsApi = { export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'), get: () => api.get<AppSettings>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
} }
export const designsApi = { export const designsApi = {
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
})) }))
vi.mock('@/stores/canvasStore', () => ({ vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
sel({ hideIp: false, serviceStatuses: {} }),
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
})) }))
vi.mock('@/utils/themes', () => ({ vi.mock('@/utils/themes', () => ({
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons' import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden"> <div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{services.map((svc, idx) => { {services.map((svc, idx) => {
const url = getServiceUrl(svc, serviceHost) const url = getServiceUrl(svc, serviceHost)
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
const row = ( const row = (
<div <div
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden" className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{/* LEFT: service name */} {/* LEFT: service name */}
<span <span
className="font-medium truncate" className="font-medium truncate"
style={{ minWidth: 0 }} style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name} title={svc.service_name}
> >
{svc.service_name} {svc.service_name}
@@ -20,6 +20,8 @@ interface SettingsModalProps {
export function SettingsModal({ open, onClose }: SettingsModalProps) { export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [interval, setIntervalValue] = useState(60) const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
const [serviceInterval, setServiceInterval] = useState(300)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings) const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
@@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
useEffect(() => { useEffect(() => {
if (!open || STANDALONE) return if (!open || STANDALONE) return
settingsApi.get() 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 */}) .catch(() => {/* use default */})
}, [open]) }, [open])
@@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
} }
setSaving(true) setSaving(true)
try { 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') toast.success('Settings saved')
onClose() onClose()
} catch { } catch {
@@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
<p className="text-[10px] text-muted-foreground leading-tight"> <p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH) How often node health is polled (ping, HTTP, SSH)
</p> </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> </div>
)} )}
@@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore'
describe('SettingsModal', () => { describe('SettingsModal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(settingsApi.get).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 } } 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.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
}) })
@@ -47,7 +47,7 @@ describe('SettingsModal', () => {
fireEvent.change(input, { target: { value: '180' } }) fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => { 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(toast.success).toHaveBeenCalledWith('Settings saved')
expect(onClose).toHaveBeenCalled() expect(onClose).toHaveBeenCalled()
}) })
@@ -76,6 +76,20 @@ describe('SettingsModal', () => {
expect(localStorage.getItem('homelable.hideIp')).toBe('true') 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 () => { it('calls onClose on Cancel', async () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />) render(<SettingsModal open onClose={onClose} />)
+10 -5
View File
@@ -3,8 +3,8 @@ import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeO
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp' import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
@@ -22,6 +22,7 @@ const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
@@ -314,7 +315,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? ( editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus /> <ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : ( ) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> <ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
) )
)} )}
</div> </div>
@@ -673,9 +674,13 @@ const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
} }
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' // Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
// A live offline service overrides the category colour with red.
const color = status === 'offline' ? '#f85149' : categoryColor
const pathLabel = svc.path?.trim() ? svc.path.trim() : '' const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
return ( return (
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore') vi.mock('@/stores/canvasStore', async (importActual) => ({
...(await importActual<typeof canvasStore>()),
useCanvasStore: vi.fn(),
}))
function makeNode(data: Partial<NodeData>): Node<NodeData> { function makeNode(data: Partial<NodeData>): Node<NodeData> {
return { return {
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
} }
} }
function setupStore(nodeData: Partial<NodeData> = {}) { function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ const state = {
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [], selectedNodeIds: [],
@@ -33,7 +36,12 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(), createGroup: vi.fn(),
ungroup: vi.fn(), ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) serviceStatuses,
}
// Support both the bare destructure call and the selector-based call.
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
} }
describe('DetailPanel', () => { describe('DetailPanel', () => {
@@ -502,6 +510,43 @@ describe('DetailPanel', () => {
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A') expect(screen.getByText('health').tagName).not.toBe('A')
}) })
it('colors a categoryless but reachable web service blue, not grey', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: 'nginx' })
expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
it('keeps a categoryless unreachable service grey', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
})
it('respects an explicit category over the url fallback', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
})
it('paints a service red when its live status is offline', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'offline' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
})
it('keeps the category colour when the live status is online', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'online' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
}) })
describe('Last Seen formatting', () => { describe('Last Seen formatting', () => {
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn() const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn() const mockNotifyScanDeviceFound = vi.fn()
const mockSetServiceStatuses = vi.fn()
class MockWebSocket { class MockWebSocket {
static instances: MockWebSocket[] = [] static instances: MockWebSocket[] = []
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode, updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound, notifyScanDeviceFound: mockNotifyScanDeviceFound,
setServiceStatuses: mockSetServiceStatuses,
} as ReturnType<typeof useCanvasStore>) } as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({ vi.mocked(useAuthStore).mockReturnValue({
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
mockUpdateNode.mockClear() mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear() mockNotifyScanDeviceFound.mockClear()
mockSetServiceStatuses.mockClear()
}) })
it('does not open WebSocket when not authenticated', () => { it('does not open WebSocket when not authenticated', () => {
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
expect(mockUpdateNode).not.toHaveBeenCalled() 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', () => { it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling()) renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0] const ws = MockWebSocket.instances[0]
+12 -2
View File
@@ -1,6 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import type { ServiceStatus } from '@/types'
interface ServiceStatusEntry {
port?: number
protocol?: string
status: ServiceStatus
}
interface StatusMessage { interface StatusMessage {
type?: string type?: string
@@ -10,13 +17,14 @@ interface StatusMessage {
response_time_ms?: number | null response_time_ms?: number | null
run_id?: string run_id?: string
devices_found?: number devices_found?: number
services?: ServiceStatusEntry[]
} }
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() { export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore() const { isAuthenticated, token } = useAuthStore()
useEffect(() => { useEffect(() => {
@@ -39,6 +47,8 @@ export function useStatusPolling() {
const msg: StatusMessage = JSON.parse(event.data) const msg: StatusMessage = JSON.parse(event.data)
if (msg.type === 'scan_device_found') { if (msg.type === 'scan_device_found') {
notifyScanDeviceFound() 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) { } else if (msg.node_id && msg.status) {
updateNode(msg.node_id, { updateNode(msg.node_id, {
status: msg.status, status: msg.status,
@@ -59,5 +69,5 @@ export function useStatusPolling() {
ws.close() ws.close()
wsRef.current = null wsRef.current = null
} }
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound]) }, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
} }
@@ -31,9 +31,36 @@ describe('canvasStore', () => {
past: [], past: [],
future: [], future: [],
clipboard: { nodes: [], edges: [] }, 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', () => { it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState() const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1') setEditingTextId('t1')
+19 -1
View File
@@ -9,7 +9,7 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } 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 { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils' 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. */ /** 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 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 { interface CanvasState {
nodes: Node<NodeData>[] nodes: Node<NodeData>[]
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
@@ -28,6 +32,8 @@ interface CanvasState {
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[] selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus>
// History // History
past: HistoryEntry[] past: HistoryEntry[]
@@ -68,6 +74,7 @@ interface CanvasState {
fitViewPending: boolean fitViewPending: boolean
clearFitViewPending: () => void clearFitViewPending: () => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
setHideIp: (value: boolean) => void setHideIp: (value: boolean) => void
@@ -86,6 +93,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
editingTextId: null, editingTextId: null,
hideIp: readHideIp(), hideIp: readHideIp(),
scanEventTs: 0, scanEventTs: 0,
serviceStatuses: {},
fitViewPending: false, fitViewPending: false,
past: [], past: [],
@@ -581,6 +589,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), 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) => { toggleHideIp: () => set((s) => {
const hideIp = !s.hideIp const hideIp = !s.hideIp
writeHideIp(hideIp) writeHideIp(hideIp)
+2
View File
@@ -78,6 +78,8 @@ export interface ServiceInfo {
category?: string category?: string
} }
export type ServiceStatus = 'online' | 'offline' | 'unknown'
export interface NodeProperty { export interface NodeProperty {
key: string key: string
value: string value: string