Skip to content

Commit f348ffe

Browse files
committed
Add AdminConsoleClient.fetch_thumbnails for cross-host dashboard
Headless side of Phase 6.5: a per-host base64 PNG screenshot fetched in parallel via the existing /screenshot REST endpoint. Returns {label: png_bytes} (None for hosts that errored), driven by the same ThreadPoolExecutor as poll_all and broadcast_execute. The GUI grid that consumes this is the next step — left as a follow-up so the headless probe + tests can land independently. 6 new headless tests covering happy path, HTTP errors, malformed responses, bad base64, label filtering, and the no-hosts case.
1 parent 1746b1c commit f348ffe

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

‎je_auto_control/utils/admin/admin_client.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,41 @@ def poll_all(self, *, labels: Optional[List[str]] = None) -> List[HostStatus]:
103103
with ThreadPoolExecutor(max_workers=self._max_parallel) as pool:
104104
return list(pool.map(self._poll_one, targets))
105105

106+
def fetch_thumbnails(self, *, labels: Optional[List[str]] = None,
107+
) -> Dict[str, Optional[bytes]]:
108+
"""Phase 6.5: pull a base64 PNG screenshot from every targeted host.
109+
110+
Returns ``label → png_bytes`` (or ``None`` on a host that
111+
errored). The Cross-host Dashboard polls this on a timer and
112+
scales the resulting image down to a thumbnail tile.
113+
"""
114+
import base64
115+
targets = self._resolve_targets(labels)
116+
if not targets:
117+
return {}
118+
119+
def grab(host: AdminHost) -> tuple:
120+
try:
121+
body = self._http_get(host, "/screenshot")
122+
except (OSError, ValueError, TimeoutError) as error:
123+
autocontrol_logger.info(
124+
"admin: thumbnail %s failed: %r", host.label, error,
125+
)
126+
return host.label, None
127+
if not isinstance(body, dict) or body.get("encoding") != "base64":
128+
return host.label, None
129+
data = body.get("data")
130+
if not isinstance(data, str):
131+
return host.label, None
132+
try:
133+
return host.label, base64.b64decode(data)
134+
except (ValueError, base64.binascii.Error):
135+
return host.label, None
136+
137+
with ThreadPoolExecutor(max_workers=self._max_parallel) as pool:
138+
results = list(pool.map(grab, targets))
139+
return dict(results)
140+
106141
def broadcast_execute(self, actions: List[Any],
107142
*, labels: Optional[List[str]] = None,
108143
) -> List[Dict[str, Any]]:
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Phase 6.5: tests for AdminConsoleClient.fetch_thumbnails."""
2+
import base64
3+
import json
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from je_auto_control.utils.admin.admin_client import AdminConsoleClient
9+
10+
11+
_FAKE_PNG = b"\x89PNG\r\n\x1a\nFAKE-PAYLOAD-BYTES"
12+
13+
14+
@pytest.fixture
15+
def client(tmp_path):
16+
c = AdminConsoleClient(persist_path=tmp_path / "hosts.json", timeout_s=1.0)
17+
c.add_host("alpha", "http://a.example", "tok-a")
18+
c.add_host("beta", "http://b.example", "tok-b")
19+
return c
20+
21+
22+
def test_fetch_thumbnails_returns_png_bytes_per_host(client):
23+
encoded = base64.b64encode(_FAKE_PNG).decode("ascii")
24+
body = {"format": "png", "encoding": "base64", "data": encoded}
25+
26+
def fake_get(self, host, path):
27+
return body
28+
29+
with patch.object(AdminConsoleClient, "_http_get", new=fake_get):
30+
out = client.fetch_thumbnails()
31+
assert set(out.keys()) == {"alpha", "beta"}
32+
assert out["alpha"] == _FAKE_PNG
33+
assert out["beta"] == _FAKE_PNG
34+
35+
36+
def test_fetch_thumbnails_returns_none_on_http_error(client):
37+
def fake_get(self, host, path):
38+
raise OSError("connection refused")
39+
40+
with patch.object(AdminConsoleClient, "_http_get", new=fake_get):
41+
out = client.fetch_thumbnails()
42+
assert out == {"alpha": None, "beta": None}
43+
44+
45+
def test_fetch_thumbnails_returns_none_on_malformed_response(client):
46+
def fake_get(self, host, path):
47+
# Missing the expected "encoding": "base64".
48+
return {"format": "png", "data": "Zm9v"}
49+
50+
with patch.object(AdminConsoleClient, "_http_get", new=fake_get):
51+
out = client.fetch_thumbnails()
52+
assert out == {"alpha": None, "beta": None}
53+
54+
55+
def test_fetch_thumbnails_returns_none_for_bad_base64(client):
56+
def fake_get(self, host, path):
57+
return {"format": "png", "encoding": "base64", "data": "%%not-b64%%"}
58+
59+
with patch.object(AdminConsoleClient, "_http_get", new=fake_get):
60+
out = client.fetch_thumbnails()
61+
# Both hosts produce None on decode failure (clean degradation).
62+
assert all(v is None for v in out.values())
63+
64+
65+
def test_fetch_thumbnails_filters_by_label(client):
66+
encoded = base64.b64encode(_FAKE_PNG).decode("ascii")
67+
body = {"format": "png", "encoding": "base64", "data": encoded}
68+
69+
def fake_get(self, host, path):
70+
return body
71+
72+
with patch.object(AdminConsoleClient, "_http_get", new=fake_get):
73+
out = client.fetch_thumbnails(labels=["beta"])
74+
assert set(out.keys()) == {"beta"}
75+
76+
77+
def test_fetch_thumbnails_returns_empty_when_no_hosts(tmp_path):
78+
client = AdminConsoleClient(persist_path=tmp_path / "hosts.json")
79+
assert client.fetch_thumbnails() == {}
80+
assert client.fetch_thumbnails(labels=["nope"]) == {}

0 commit comments

Comments
 (0)