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:
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user