From aa0a97c2454334d7c8f895f82982347e8f6b3aee Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 9 Jun 2026 21:20:05 +0200 Subject: [PATCH 1/5] fix: reduce status flapping, add IPv6 ping, color manual web services Addresses three reports from issue #196: - Ping now sends 2 probes with a ~2s timeout (was 1 probe / 1s) so a single dropped packet or a slow IoT/ESPHome device no longer flaps a node offline (#196.1, #196.2). - IPv6-only devices (e.g. Alexa) are now pinged over IPv6: ping6 on macOS, -6 flag on Linux/Windows, detected via inet_pton (#196.3). - Manually-added services carry no category and so always rendered grey even when they were reachable HTTP/HTTPS. A resolvable web URL now falls back to the web colour (#196.9). ha-relevant: yes --- backend/app/services/status_checker.py | 32 ++++++-- backend/tests/test_status_checker.py | 80 ++++++++++++++++++- .../src/components/panels/DetailPanel.tsx | 4 +- .../panels/__tests__/DetailPanel.test.tsx | 19 +++++ 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py index 8a75126..27633b0 100644 --- a/backend/app/services/status_checker.py +++ b/backend/app/services/status_checker.py @@ -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} +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: - # ping(8) -W flag units differ by OS: - # Linux: seconds (-W 1 = 1s) - # macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms) - # Windows: -w in ms (-w 1000 = 1s) + # Send 2 probes with a ~2s timeout so a single dropped packet or a slow + # device (ESPHome, IoT) doesn't flap a node offline. Success = any reply. + # + # -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": - 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": - args = ["ping", "-c", "1", "-W", "1000", host] + args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host] 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( *args, stdout=asyncio.subprocess.DEVNULL, diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py index 205e62d..0dd1d73 100644 --- a/backend/tests/test_status_checker.py +++ b/backend/tests/test_status_checker.py @@ -169,9 +169,14 @@ async def test_ping_uses_unix_args_on_non_windows(): assert "-c" in captured["args"] assert "-W" 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") - 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 @@ -193,7 +198,7 @@ async def test_ping_uses_macos_millisecond_timeout(): assert "-c" in captured["args"] assert "-W" in captured["args"] w_idx = captured["args"].index("-W") - assert captured["args"][w_idx + 1] == "1000" + assert captured["args"][w_idx + 1] == "2000" @pytest.mark.asyncio @@ -216,6 +221,75 @@ async def test_ping_uses_windows_args_on_win32(): 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 --- @pytest.mark.asyncio diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index d8896bf..1728fcc 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -675,7 +675,9 @@ const CATEGORY_COLORS: Record = { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { 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 color = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e') const pathLabel = svc.path?.trim() ? svc.path.trim() : '' return ( diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index bbbd38a..f822f64 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -502,6 +502,25 @@ describe('DetailPanel', () => { render() 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() + 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() + 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() + expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database) + }) }) describe('Last Seen formatting', () => { From bcc488993da3e899f79e7345d38d2176269e2072 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 10 Jun 2026 02:13:15 +0200 Subject: [PATCH 2/5] feat: per-service status checks with offline colouring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes/settings.py | 17 ++- backend/app/api/routes/status.py | 9 ++ backend/app/core/config.py | 10 ++ backend/app/core/scheduler.py | 73 ++++++++++- backend/app/services/status_checker.py | 71 +++++++++++ backend/tests/test_scheduler.py | 96 +++++++++++++- backend/tests/test_settings.py | 39 ++++++ backend/tests/test_status_checker.py | 117 +++++++++++++++++- frontend/src/api/__tests__/client.test.ts | 4 +- frontend/src/api/client.ts | 10 +- .../canvas/__tests__/BaseNode.test.tsx | 4 +- .../src/components/canvas/nodes/BaseNode.tsx | 6 +- .../src/components/modals/SettingsModal.tsx | 44 ++++++- .../modals/__tests__/SettingsModal.test.tsx | 20 ++- .../src/components/panels/DetailPanel.tsx | 13 +- .../panels/__tests__/DetailPanel.test.tsx | 34 ++++- .../hooks/__tests__/useStatusPolling.test.ts | 14 +++ frontend/src/hooks/useStatusPolling.ts | 14 ++- .../src/stores/__tests__/canvasStore.test.ts | 27 ++++ frontend/src/stores/canvasStore.ts | 20 ++- frontend/src/types/index.ts | 2 + 21 files changed, 615 insertions(+), 29 deletions(-) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 5205685..7ec17bb 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -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 diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py index 4f1d012..ec544e2 100644 --- a/backend/app/api/routes/status.py +++ b/backend/app/api/routes/status.py @@ -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", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3eb3882..f599f74 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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, })) diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index 064db51..955c8f2 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -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) diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py index 27633b0..dd2a1da 100644 --- a/backend/app/services/status_checker.py +++ b/backend/app/services/status_checker.py @@ -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 [] diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index e688fd6..078ea1a 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -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 diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index a5b8069..c30c858 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -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 diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py index 0dd1d73..d384f02 100644 --- a/backend/tests/test_status_checker.py +++ b/backend/tests/test_status_checker.py @@ -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", []) == [] diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index 91af73b..46de58d 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -204,8 +204,8 @@ describe('api/client', () => { it('settingsApi get/save', () => { mod.settingsApi.get() expect(api.get).toHaveBeenCalledWith('/settings') - mod.settingsApi.save({ interval_seconds: 30 }) - expect(api.post).toHaveBeenCalledWith('/settings', { 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, service_check_enabled: true, service_check_interval: 600 }) }) it('zigbeeApi.testConnection/importNetwork/importToPending', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a648711..96a8ebe 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -90,9 +90,15 @@ export const scanApi = { 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 = { - get: () => api.get<{ interval_seconds: number }>('/settings'), - save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), + get: () => api.get('/settings'), + save: (data: AppSettings) => api.post('/settings', data), } export const designsApi = { diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index d320d77..fa42379 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({ })) vi.mock('@/stores/canvasStore', () => ({ - useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), + useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record }) => unknown) => + sel({ hideIp: false, serviceStatuses: {} }), + serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`, })) vi.mock('@/utils/themes', () => ({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index c65faee..d3f356b 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon' import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' -import { useCanvasStore } from '@/stores/canvasStore' +import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' 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 hideIp = useCanvasStore((s) => s.hideIp) + const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const theme = THEMES[activeTheme] const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) @@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{services.map((svc, idx) => { const url = getServiceUrl(svc, serviceHost) + const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline' const row = (
{svc.service_name} diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index 5804b34..647997f 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -20,6 +20,8 @@ interface SettingsModalProps { export function SettingsModal({ open, onClose }: SettingsModalProps) { const [interval, setIntervalValue] = useState(60) + const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false) + const [serviceInterval, setServiceInterval] = useState(300) const [saving, setSaving] = useState(false) const [alignment, setAlignment] = useState(readAlignmentSettings) const hideIp = useCanvasStore((s) => s.hideIp) @@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { useEffect(() => { if (!open || STANDALONE) return settingsApi.get() - .then((res) => setIntervalValue(res.data.interval_seconds)) + .then((res) => { + setIntervalValue(res.data.interval_seconds) + setServiceCheckEnabled(res.data.service_check_enabled) + setServiceInterval(res.data.service_check_interval) + }) .catch(() => {/* use default */}) }, [open]) @@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } setSaving(true) try { - await settingsApi.save({ interval_seconds: interval }) + await settingsApi.save({ + interval_seconds: interval, + service_check_enabled: serviceCheckEnabled, + service_check_interval: serviceInterval, + }) toast.success('Settings saved') onClose() } catch { @@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {

How often node health is polled (ping, HTTP, SSH…)

+ + + +
+ +
+ { 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" + /> + seconds +
+

+ Probes each service port. Offline services turn red. Default 300s (5 min). +

+
)} diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx index 7da48d8..c0004f3 100644 --- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore' describe('SettingsModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never) - vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never) + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never) + vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never) vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() }) @@ -47,7 +47,7 @@ describe('SettingsModal', () => { fireEvent.change(input, { target: { value: '180' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { - expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 }) + expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 }) expect(toast.success).toHaveBeenCalledWith('Settings saved') expect(onClose).toHaveBeenCalled() }) @@ -76,6 +76,20 @@ describe('SettingsModal', () => { expect(localStorage.getItem('homelable.hideIp')).toBe('true') }) + it('loads and toggles the per-service check setting, saving its interval', async () => { + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never) + render() + const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement + expect(toggle.checked).toBe(true) + expect(await screen.findByDisplayValue('600')).toBeDefined() + + fireEvent.click(toggle) // disable + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 }) + }) + }) + it('calls onClose on Cancel', async () => { const onClose = vi.fn() render() diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 1728fcc..190731e 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -3,8 +3,8 @@ import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeO import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { useCanvasStore } from '@/stores/canvasStore' -import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' +import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore' +import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' import { splitIps } from '@/utils/maskIp' 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) { const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() + const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) const [newSvc, setNewSvc] = useState(EMPTY_FORM) @@ -314,7 +315,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { editingIndex === i ? ( setEditingFor(null)} confirmLabel="Save" autoFocus /> ) : ( - handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> + handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> ) )}
@@ -673,11 +674,13 @@ const CATEGORY_COLORS: Record = { 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) // 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 color = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e') + 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() : '' return ( diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index f822f64..c610eab 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore' import type { NodeData } from '@/types' import type { Node } from '@xyflow/react' -vi.mock('@/stores/canvasStore') +vi.mock('@/stores/canvasStore', async (importActual) => ({ + ...(await importActual()), + useCanvasStore: vi.fn(), +})) function makeNode(data: Partial): Node { return { @@ -22,8 +25,8 @@ function makeNode(data: Partial): Node { } } -function setupStore(nodeData: Partial = {}) { - vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ +function setupStore(nodeData: Partial = {}, serviceStatuses: Record = {}) { + const state = { nodes: [makeNode(nodeData)], selectedNodeId: 'n1', selectedNodeIds: [], @@ -33,7 +36,12 @@ function setupStore(nodeData: Partial = {}) { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - } as unknown as ReturnType) + 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', () => { @@ -521,6 +529,24 @@ describe('DetailPanel', () => { render() 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() + 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() + expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web) + }) }) describe('Last Seen formatting', () => { diff --git a/frontend/src/hooks/__tests__/useStatusPolling.test.ts b/frontend/src/hooks/__tests__/useStatusPolling.test.ts index 845c231..e8df523 100644 --- a/frontend/src/hooks/__tests__/useStatusPolling.test.ts +++ b/frontend/src/hooks/__tests__/useStatusPolling.test.ts @@ -9,6 +9,7 @@ vi.mock('@/stores/authStore') const mockUpdateNode = vi.fn() const mockNotifyScanDeviceFound = vi.fn() +const mockSetServiceStatuses = vi.fn() class MockWebSocket { static instances: MockWebSocket[] = [] @@ -33,6 +34,7 @@ describe('useStatusPolling', () => { vi.mocked(useCanvasStore).mockReturnValue({ updateNode: mockUpdateNode, notifyScanDeviceFound: mockNotifyScanDeviceFound, + setServiceStatuses: mockSetServiceStatuses, } as ReturnType) vi.mocked(useAuthStore).mockReturnValue({ @@ -50,6 +52,7 @@ describe('useStatusPolling', () => { vi.restoreAllMocks() mockUpdateNode.mockClear() mockNotifyScanDeviceFound.mockClear() + mockSetServiceStatuses.mockClear() }) it('does not open WebSocket when not authenticated', () => { @@ -147,6 +150,17 @@ describe('useStatusPolling', () => { expect(mockUpdateNode).not.toHaveBeenCalled() }) + it('routes service_status messages to setServiceStatuses', () => { + renderHook(() => useStatusPolling()) + const ws = MockWebSocket.instances[0] + const services = [{ port: 80, protocol: 'tcp', status: 'offline' }] + ws.onmessage?.({ + data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }), + }) + expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services) + expect(mockUpdateNode).not.toHaveBeenCalled() + }) + it('ignores malformed JSON without throwing', () => { renderHook(() => useStatusPolling()) const ws = MockWebSocket.instances[0] diff --git a/frontend/src/hooks/useStatusPolling.ts b/frontend/src/hooks/useStatusPolling.ts index 192a005..defd2bc 100644 --- a/frontend/src/hooks/useStatusPolling.ts +++ b/frontend/src/hooks/useStatusPolling.ts @@ -1,6 +1,13 @@ import { useEffect, useRef } from 'react' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' +import type { ServiceStatus } from '@/types' + +interface ServiceStatusEntry { + port?: number + protocol?: string + status: ServiceStatus +} interface StatusMessage { type?: string @@ -10,13 +17,14 @@ interface StatusMessage { response_time_ms?: number | null run_id?: string devices_found?: number + services?: ServiceStatusEntry[] } const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export function useStatusPolling() { const wsRef = useRef(null) - const { updateNode, notifyScanDeviceFound } = useCanvasStore() + const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore() const { isAuthenticated, token } = useAuthStore() useEffect(() => { @@ -39,6 +47,8 @@ export function useStatusPolling() { const msg: StatusMessage = JSON.parse(event.data) if (msg.type === 'scan_device_found') { notifyScanDeviceFound() + } else if (msg.type === 'service_status' && msg.node_id && msg.services) { + setServiceStatuses(msg.node_id, msg.services) } else if (msg.node_id && msg.status) { updateNode(msg.node_id, { status: msg.status, @@ -59,5 +69,5 @@ export function useStatusPolling() { ws.close() wsRef.current = null } - }, [isAuthenticated, token, updateNode, notifyScanDeviceFound]) + }, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses]) } diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 370b00a..8b8d05c 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -31,9 +31,36 @@ describe('canvasStore', () => { past: [], future: [], 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', () => { const { setEditingTextId } = useCanvasStore.getState() setEditingTextId('t1') diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 9b0f8eb..24d8fa0 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,7 +9,7 @@ import { applyEdgeChanges, addEdge, } 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 { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' @@ -21,6 +21,10 @@ type Clipboard = { nodes: Node[]; edges: Edge[] } /** Resolve a node's effective parent id from either the RF field or domain data. */ const parentIdOf = (n: Node): 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 { nodes: Node[] edges: Edge[] @@ -28,6 +32,8 @@ interface CanvasState { selectedNodeId: string | null selectedNodeIds: string[] scanEventTs: number + // Live per-service status overlay (not persisted), keyed via serviceStatusKey. + serviceStatuses: Record // History past: HistoryEntry[] @@ -68,6 +74,7 @@ interface CanvasState { fitViewPending: boolean clearFitViewPending: () => void notifyScanDeviceFound: () => void + setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void hideIp: boolean toggleHideIp: () => void setHideIp: (value: boolean) => void @@ -86,6 +93,7 @@ export const useCanvasStore = create((set) => ({ editingTextId: null, hideIp: readHideIp(), scanEventTs: 0, + serviceStatuses: {}, fitViewPending: false, past: [], @@ -581,6 +589,16 @@ export const useCanvasStore = create((set) => ({ 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) => { const hideIp = !s.hideIp writeHideIp(hideIp) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index db378e4..97b6b92 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -78,6 +78,8 @@ export interface ServiceInfo { category?: string } +export type ServiceStatus = 'online' | 'offline' | 'unknown' + export interface NodeProperty { key: string value: string From d032103b7d0f89c3085164a2b4b1f45dee6d22b7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 10 Jun 2026 02:44:06 +0200 Subject: [PATCH 3/5] fix(types): add type params to broadcast_service_status services arg ha-relevant: yes --- backend/app/api/routes/status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py index ec544e2..b119cdc 100644 --- a/backend/app/api/routes/status.py +++ b/backend/app/api/routes/status.py @@ -54,7 +54,7 @@ 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: +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, From 2db173cd5654b0854a1c895c9b8821a090ce1055 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 10 Jun 2026 09:58:06 +0200 Subject: [PATCH 4/5] fix(ws): idempotent connection removal, release slot on any error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status WebSocket pool removed connections with list.remove(), which raises ValueError on a double-remove (broadcast already dropped a dead socket, then disconnect tries again), and only released a slot on WebSocketDisconnect — any other error leaked the socket into the broadcast pool. Centralise removal in an idempotent _drop() called from a finally block and from _broadcast. ha-relevant: yes --- backend/app/api/routes/status.py | 15 ++++++- backend/tests/test_status.py | 68 +++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py index b119cdc..26c5c4f 100644 --- a/backend/app/api/routes/status.py +++ b/backend/app/api/routes/status.py @@ -1,3 +1,4 @@ +import contextlib import json from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -10,6 +11,12 @@ router = APIRouter() _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") async def ws_status(websocket: WebSocket) -> None: # 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: await websocket.receive_text() 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: @@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None: try: await conn.send_text(payload) 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: diff --git a/backend/tests/test_status.py b/backend/tests/test_status.py index b9dfcba..74a6da9 100644 --- a/backend/tests/test_status.py +++ b/backend/tests/test_status.py @@ -5,7 +5,13 @@ import pytest from fastapi.testclient import TestClient 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 # --------------------------------------------------------------------------- @@ -155,3 +161,63 @@ async def test_broadcast_no_connections(): assert len(_connections) == 0 await broadcast_status(node_id="n", status="online", checked_at="t") 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 From ed310f0d8daf43855057f195b762f7e67d825772 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 10 Jun 2026 10:48:03 +0200 Subject: [PATCH 5/5] fix(status): keep non-HTTP services grey instead of red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-service checks now only probe HTTP(S)-reachable services. SSH (22) and other non-web ports (DB, mail, DNS, raw TCP) stay 'unknown' (grey category colour) rather than going red — an open TCP socket doesn't prove the service is healthy, and a firewalled port flapped red misleadingly. ha-relevant: yes --- backend/app/services/status_checker.py | 47 +++++++++++++------------- backend/tests/test_status_checker.py | 33 ++++++++++-------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py index dd2a1da..8dc963a 100644 --- a/backend/app/services/status_checker.py +++ b/backend/app/services/status_checker.py @@ -122,10 +122,12 @@ async def _tcp_connect(host: str, port: int) -> bool: # --- 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. +# 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({ - 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514, + 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}) @@ -139,9 +141,10 @@ def _service_host(svc: dict[str, Any], host: str) -> str: 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. + 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" @@ -151,24 +154,22 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str: 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" - + # 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" diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py index d384f02..6555585 100644 --- a/backend/tests/test_status_checker.py +++ b/backend/tests/test_status_checker.py @@ -410,19 +410,25 @@ async def test_check_service_web_offline_when_http_fails(): @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 - +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", side_effect=fake_tcp): + 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 == "online" - assert captured == {"host": "10.0.0.1", "port": 5432} + 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 @@ -445,12 +451,11 @@ async def test_check_services_returns_status_per_service(): {"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): + 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": "offline"}, + {"port": 5432, "protocol": "tcp", "status": "unknown"}, ]