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', () => {