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
This commit is contained in:
Pouzor
2026-06-09 21:20:05 +02:00
parent a39d4d4406
commit aa0a97c245
4 changed files with 124 additions and 11 deletions
+25 -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}
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,
+77 -3
View File
@@ -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
@@ -675,7 +675,9 @@ const CATEGORY_COLORS: Record<string, string> = {
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 (
@@ -502,6 +502,25 @@ describe('DetailPanel', () => {
render(<DetailPanel onEdit={vi.fn()} />)
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)
})
})
describe('Last Seen formatting', () => {