fix: zero-session devices show 'No sessions' tile + grace period prevents flapping
Two federation UX fixes:
1. Zero-session devices: when remote returns empty sessions list, return
{status: 'empty'} entry. Frontend renders 'No sessions' status tile
instead of making the device invisible.
2. Flapping prevention: server-side cache of last-known-good federation
results per remote. On transient failure, return cached sessions for
up to 3 consecutive failures before marking unreachable. Eliminates
the visible on/off/on/off pattern caused by occasional 5-second
timeouts during 2-second poll cycles.
Tests added:
- test_fetch_remote_returns_empty_status_for_zero_sessions (Python)
- test_fetch_remote_uses_cache_on_transient_failure (Python)
- test_fetch_remote_marks_unreachable_after_grace_period (Python)
- renderGrid shows 'No sessions' status tile for status=empty devices (JS)
Also adds reset_federation_cache autouse fixture to prevent cross-test
contamination from the new module-level _federation_cache dict.
This commit is contained in:
@@ -762,6 +762,7 @@ function renderGrid(sessions) {
|
|||||||
(sessions || []).forEach(function(session) {
|
(sessions || []).forEach(function(session) {
|
||||||
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
||||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
||||||
|
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
||||||
});
|
});
|
||||||
if (grid) grid.innerHTML = statusTilesHtml;
|
if (grid) grid.innerHTML = statusTilesHtml;
|
||||||
// Only show empty-state when there are truly no tiles at all
|
// Only show empty-state when there are truly no tiles at all
|
||||||
@@ -800,11 +801,12 @@ function renderGrid(sessions) {
|
|||||||
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
|
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append status tiles for auth_failed and unreachable sessions
|
// Append status tiles for auth_failed, unreachable, and empty sessions
|
||||||
var statusTilesHtml = '';
|
var statusTilesHtml = '';
|
||||||
(sessions || []).forEach(function(session) {
|
(sessions || []).forEach(function(session) {
|
||||||
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
||||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
||||||
|
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
||||||
});
|
});
|
||||||
if (grid) grid.innerHTML = html + statusTilesHtml;
|
if (grid) grid.innerHTML = html + statusTilesHtml;
|
||||||
|
|
||||||
|
|||||||
@@ -1002,6 +1002,11 @@ body {
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-tile--empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
.source-tile__name {
|
.source-tile__name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -4758,3 +4758,35 @@ test('renderGrid status tiles use session.deviceName not session.name for offlin
|
|||||||
|
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- renderGrid: status=empty shows "No sessions" tile ---
|
||||||
|
|
||||||
|
test('renderGrid shows "No sessions" status tile for status=empty devices', () => {
|
||||||
|
// A device that is online but has zero tmux sessions returns
|
||||||
|
// {status: 'empty', deviceName: '...'} from the federation endpoint.
|
||||||
|
// renderGrid must render a status tile with the text "No sessions" (not blank).
|
||||||
|
//
|
||||||
|
// Before implementation: fails because neither status loop handles status === 'empty',
|
||||||
|
// so the tile is never rendered and grid.innerHTML stays empty.
|
||||||
|
const grid = { innerHTML: '' };
|
||||||
|
const emptyState = { style: {}, classList: { add() {}, remove() {} } };
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
globalThis.document.getElementById = (id) => {
|
||||||
|
if (id === 'session-grid') return grid;
|
||||||
|
if (id === 'empty-state') return emptyState;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.renderGrid([{ status: 'empty', deviceName: 'quiet-box', remoteId: 3 }]);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
grid.innerHTML.includes('No sessions'),
|
||||||
|
`renderGrid must include "No sessions" text for status=empty device, got: ${grid.innerHTML}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
grid.innerHTML.includes('quiet-box'),
|
||||||
|
`renderGrid must include the deviceName "quiet-box" in the status tile, got: ${grid.innerHTML}`
|
||||||
|
);
|
||||||
|
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
});
|
||||||
|
|||||||
+37
-2
@@ -1182,6 +1182,13 @@ async def auth_mode_endpoint():
|
|||||||
return {"mode": _auth_mode, "user": username}
|
return {"mode": _auth_mode, "user": username}
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level cache: remote_id → {"sessions": [...], "fail_count": int}
|
||||||
|
# Populated by fetch_remote() on every successful poll; returned on transient failures
|
||||||
|
# so a single slow/dropped request doesn't immediately evict a device from the UI.
|
||||||
|
_federation_cache: dict[int, dict] = {}
|
||||||
|
_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/federation/sessions")
|
@app.get("/api/federation/sessions")
|
||||||
async def federation_sessions(request: Request) -> list[dict]:
|
async def federation_sessions(request: Request) -> list[dict]:
|
||||||
"""Fetch sessions from all instances (local + remotes) and merge.
|
"""Fetch sessions from all instances (local + remotes) and merge.
|
||||||
@@ -1220,7 +1227,12 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
http_client: httpx.AsyncClient = request.app.state.federation_client
|
http_client: httpx.AsyncClient = request.app.state.federation_client
|
||||||
|
|
||||||
async def fetch_remote(i: int, remote: dict) -> list[dict]:
|
async def fetch_remote(i: int, remote: dict) -> list[dict]:
|
||||||
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry."""
|
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry.
|
||||||
|
|
||||||
|
On success: cache the result and return tagged sessions (or {status: 'empty'} if none).
|
||||||
|
On transient failure: return cached sessions for up to _FEDERATION_GRACE_FAILURES
|
||||||
|
consecutive failures before promoting to {status: 'unreachable'}.
|
||||||
|
"""
|
||||||
url: str = remote.get("url", "")
|
url: str = remote.get("url", "")
|
||||||
key: str = remote.get("key", "")
|
key: str = remote.get("key", "")
|
||||||
remote_name: str = remote.get("name", url)
|
remote_name: str = remote.get("name", url)
|
||||||
@@ -1231,6 +1243,8 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
headers={"Authorization": f"Bearer {key}"} if key else {},
|
headers={"Authorization": f"Bearer {key}"} if key else {},
|
||||||
)
|
)
|
||||||
if resp.status_code in (401, 403):
|
if resp.status_code in (401, 403):
|
||||||
|
# Auth failure — clear cache so stale data is not served
|
||||||
|
_federation_cache.pop(remote_id, None)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "auth_failed",
|
"status": "auth_failed",
|
||||||
@@ -1241,7 +1255,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
sessions = resp.json()
|
sessions = resp.json()
|
||||||
# Tag each session with deviceName, remoteId, and unique sessionKey
|
# Tag each session with deviceName, remoteId, and unique sessionKey
|
||||||
return [
|
tagged = [
|
||||||
{
|
{
|
||||||
**s,
|
**s,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
@@ -1250,7 +1264,24 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
}
|
}
|
||||||
for s in sessions
|
for s in sessions
|
||||||
]
|
]
|
||||||
|
# Update cache on every successful poll (even empty)
|
||||||
|
_federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0}
|
||||||
|
if not tagged:
|
||||||
|
# Device is online but has zero tmux sessions — show a status tile
|
||||||
|
# rather than making the device completely invisible.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"status": "empty",
|
||||||
|
"remoteId": remote_id,
|
||||||
|
"deviceName": remote_name,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return tagged
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError:
|
||||||
|
cached = _federation_cache.get(remote_id)
|
||||||
|
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
||||||
|
cached["fail_count"] += 1
|
||||||
|
return cached["sessions"]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "unreachable",
|
"status": "unreachable",
|
||||||
@@ -1260,6 +1291,10 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
]
|
]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.warning("Unexpected error fetching remote %s: %s", url, exc)
|
_log.warning("Unexpected error fetching remote %s: %s", url, exc)
|
||||||
|
cached = _federation_cache.get(remote_id)
|
||||||
|
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
||||||
|
cached["fail_count"] += 1
|
||||||
|
return cached["sessions"]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "unreachable",
|
"status": "unreachable",
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ def patch_startup_and_state(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_federation_cache():
|
||||||
|
"""Clear _federation_cache before and after each test.
|
||||||
|
|
||||||
|
The module-level _federation_cache persists across tests in the same process,
|
||||||
|
causing cross-test contamination: a test that populates the cache for remoteId=0
|
||||||
|
causes a later test (also using remoteId=0) to get stale cached data instead of
|
||||||
|
the expected unreachable status.
|
||||||
|
"""
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
|
||||||
|
main_mod._federation_cache.clear()
|
||||||
|
yield
|
||||||
|
main_mod._federation_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Client fixture — TestClient with lifespan enabled
|
# Client fixture — TestClient with lifespan enabled
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3036,3 +3052,215 @@ def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatc
|
|||||||
assert local["host"] == "127.0.0.1", (
|
assert local["host"] == "127.0.0.1", (
|
||||||
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
|
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_remote: zero-session visibility and flapping grace period
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_remote_returns_empty_status_for_zero_sessions(
|
||||||
|
client, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
"""When remote /api/sessions returns [], federation returns {status: 'empty'} entry.
|
||||||
|
|
||||||
|
A device that is online but has zero tmux sessions must not vanish silently.
|
||||||
|
Instead, the endpoint must include a {status: 'empty', deviceName: ...} entry
|
||||||
|
so the frontend can render a 'No sessions' tile.
|
||||||
|
|
||||||
|
Before implementation: fails because the list comprehension returns [] for empty
|
||||||
|
session lists, and the empty list is flattened into nothing.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_instances": [
|
||||||
|
{
|
||||||
|
"url": "http://empty-host:8088",
|
||||||
|
"key": "secret",
|
||||||
|
"name": "empty-host",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
||||||
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
||||||
|
|
||||||
|
mock_resp = MagicMock(spec=httpx.Response)
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = []
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
async def mock_get(*args, **kwargs):
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
mock_fed_client = MagicMock()
|
||||||
|
mock_fed_client.get = mock_get
|
||||||
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||||
|
|
||||||
|
response = client.get("/api/federation/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1, (
|
||||||
|
f"Expected exactly one status entry for empty remote, got {len(data)} entries: {data}"
|
||||||
|
)
|
||||||
|
assert data[0].get("status") == "empty", (
|
||||||
|
f"Expected status='empty' for zero-session remote, got: {data[0].get('status')!r}"
|
||||||
|
)
|
||||||
|
assert data[0].get("deviceName") == "empty-host", (
|
||||||
|
f"Expected deviceName='empty-host', got: {data[0].get('deviceName')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_remote_uses_cache_on_transient_failure(client, monkeypatch, tmp_path):
|
||||||
|
"""When remote fails after a prior success, cached sessions are returned (grace period).
|
||||||
|
|
||||||
|
A single failed HTTP request must not immediately evict the device from the UI.
|
||||||
|
The server keeps the last-known-good result and returns it for up to
|
||||||
|
_FEDERATION_GRACE_FAILURES consecutive failures.
|
||||||
|
|
||||||
|
Before implementation: fails because fetch_remote has no cache — transient failure
|
||||||
|
immediately returns {status: 'unreachable'}.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
# Reset module-level cache so this test starts clean
|
||||||
|
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_instances": [
|
||||||
|
{"url": "http://remote:8088", "key": "k", "name": "cache-host"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
||||||
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
||||||
|
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
async def mock_get_stateful(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
mock_resp = MagicMock(spec=httpx.Response)
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = [{"name": "sess1"}, {"name": "sess2"}]
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
return mock_resp
|
||||||
|
raise httpx.TimeoutException("timeout", request=MagicMock())
|
||||||
|
|
||||||
|
mock_fed_client = MagicMock()
|
||||||
|
mock_fed_client.get = mock_get_stateful
|
||||||
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||||
|
|
||||||
|
# First call: succeeds and populates cache
|
||||||
|
r1 = client.get("/api/federation/sessions")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
d1 = [s for s in r1.json() if s.get("deviceName") == "cache-host"]
|
||||||
|
assert len(d1) == 2, f"First call must return 2 sessions, got {d1}"
|
||||||
|
|
||||||
|
# Second call: remote times out — cache should return the 2 cached sessions
|
||||||
|
r2 = client.get("/api/federation/sessions")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
d2 = [s for s in r2.json() if s.get("deviceName") == "cache-host"]
|
||||||
|
assert len(d2) == 2, f"Within grace period, must return 2 cached sessions, got {d2}"
|
||||||
|
assert not any(s.get("status") == "unreachable" for s in d2), (
|
||||||
|
"Within grace period, cached sessions must be returned, not 'unreachable'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_remote_marks_unreachable_after_grace_period(
|
||||||
|
client, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
"""After _FEDERATION_GRACE_FAILURES consecutive failures, device is marked unreachable.
|
||||||
|
|
||||||
|
The grace period prevents flapping, but must not hide a genuinely offline device
|
||||||
|
indefinitely. After 3 consecutive failures the next poll must return
|
||||||
|
{status: 'unreachable'}.
|
||||||
|
|
||||||
|
Before implementation: fails because there is no cache at all — unreachable is
|
||||||
|
returned immediately on first failure.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
# Reset module-level cache so this test starts clean
|
||||||
|
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_instances": [
|
||||||
|
{"url": "http://remote:8088", "key": "k", "name": "grace-host"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
||||||
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
||||||
|
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
async def mock_get_stateful(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
mock_resp = MagicMock(spec=httpx.Response)
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = [{"name": "sess1"}]
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
return mock_resp
|
||||||
|
raise httpx.TimeoutException("timeout", request=MagicMock())
|
||||||
|
|
||||||
|
mock_fed_client = MagicMock()
|
||||||
|
mock_fed_client.get = mock_get_stateful
|
||||||
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||||
|
|
||||||
|
# Call 1: success — populates cache
|
||||||
|
r = client.get("/api/federation/sessions")
|
||||||
|
d = r.json()
|
||||||
|
assert any(s.get("name") == "sess1" for s in d), "Call 1 must return sess1"
|
||||||
|
|
||||||
|
# Calls 2-4 (failures 1-3): within grace period — must return cached sessions
|
||||||
|
for i in range(3):
|
||||||
|
r = client.get("/api/federation/sessions")
|
||||||
|
d = r.json()
|
||||||
|
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
||||||
|
assert not any(s.get("status") == "unreachable" for s in host_entries), (
|
||||||
|
f"Call {i + 2}: fail_count={i + 1} is within grace period — "
|
||||||
|
f"must return cached sessions, not 'unreachable'. Got: {host_entries}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call 5 (failure 4): exceeds grace period — must return unreachable
|
||||||
|
r = client.get("/api/federation/sessions")
|
||||||
|
d = r.json()
|
||||||
|
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
||||||
|
assert any(s.get("status") == "unreachable" for s in host_entries), (
|
||||||
|
f"After exceeding grace period, device must be marked 'unreachable'. Got: {host_entries}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user