diff --git a/docs/superpowers/plans/2026-05-10-host-overview-info-section.md b/docs/superpowers/plans/2026-05-10-host-overview-info-section.md new file mode 100644 index 0000000..7cbb1c9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-host-overview-info-section.md @@ -0,0 +1,539 @@ +# Host Overview Info Section — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an always-visible info section to each host card on `/plugins`, showing owner, managers, agent version/type, last packet timestamp, and effective thresholds; move hbc_version/hbc_type out of the os_info accordion. + +**Architecture:** A new `_build_host_info` module-level helper in `http.py` assembles the info dict from the host object and threshold_checker. A new `GET /api/0/hosts/{hostname}/info` closure inside `serve()` calls it and returns JSON. The `plugins.html` template adds a static placeholder div per host; JS fetches the endpoint on first card expand, caches the result, and renders it. + +**Tech Stack:** Python/aiohttp (backend), Jinja2 (template), vanilla JS/HTML/CSS (frontend). Tests with pytest and unittest.mock. + +--- + +### Task 1: `_build_host_info` helper — tests first + +**Files:** +- Create: `tests/test_http_host_info.py` +- Modify: `hbd/server/http.py` (add module-level helper after `_mask_config_for_api`, around line 128) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_http_host_info.py`: + +```python +"""Tests for _build_host_info helper in http.py.""" +import pytest +from unittest.mock import MagicMock +from hbd.server.http import _build_host_info + + +class _FakeConn: + def __init__(self, lastbeat): + self.lastbeat = lastbeat + + +class _FakeHost: + def __init__(self, name="myhost", owner=None, managers=None, + connections=None, os_data=None): + self.name = name + self.owner = owner + self.managers = managers or [] + self.connections = connections or {} + self._os_data = os_data + + def get_latest_plugin_data(self, plugin_name): + if plugin_name == "os_info" and self._os_data is not None: + return (1234567890.0, self._os_data) + return None + + +def test_build_host_info_basic_fields(): + host = _FakeHost(owner="alice", managers=["bob", "carol"]) + result = _build_host_info(host) + assert result["owner"] == "alice" + assert result["managers"] == ["bob", "carol"] + assert result["hbc_version"] is None + assert result["hbc_type"] is None + assert result["last_packet"] is None + assert result["thresholds"] is None + + +def test_build_host_info_no_owner(): + host = _FakeHost() + result = _build_host_info(host) + assert result["owner"] is None + assert result["managers"] == [] + + +def test_build_host_info_reads_hbc_from_os_info(): + host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"}) + result = _build_host_info(host) + assert result["hbc_version"] == "5.3.0" + assert result["hbc_type"] == "full" + + +def test_build_host_info_hbc_none_when_no_os_info(): + host = _FakeHost(os_data=None) + result = _build_host_info(host) + assert result["hbc_version"] is None + assert result["hbc_type"] is None + + +def test_build_host_info_last_packet_is_max_lastbeat(): + host = _FakeHost(connections={ + "IPv4": _FakeConn(1000.0), + "IPv6": _FakeConn(2000.0), + }) + result = _build_host_info(host) + assert result["last_packet"] == 2000.0 + + +def test_build_host_info_last_packet_none_when_no_connections(): + host = _FakeHost(connections={}) + result = _build_host_info(host) + assert result["last_packet"] is None + + +def test_build_host_info_thresholds_none_without_checker(): + host = _FakeHost() + result = _build_host_info(host, threshold_checker=None) + assert result["thresholds"] is None + + +def test_build_host_info_thresholds_sorted_by_metric(): + from hbd.server.threshold import ThresholdConfig + tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0) + tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0) + + checker = MagicMock() + checker.get_thresholds_for_host.return_value = { + "memory_monitor.memory_percent": tc_mem, + "cpu_monitor.cpu_percent": tc_cpu, + } + + host = _FakeHost() + result = _build_host_info(host, threshold_checker=checker) + + assert result["thresholds"] is not None + assert len(result["thresholds"]) == 2 + assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent" + assert result["thresholds"][0]["warning"] == 80.0 + assert result["thresholds"][0]["critical"] == 95.0 + assert result["thresholds"][0]["operator"] == ">" + assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent" + + +def test_build_host_info_thresholds_empty_list_when_no_thresholds(): + checker = MagicMock() + checker.get_thresholds_for_host.return_value = {} + host = _FakeHost() + result = _build_host_info(host, threshold_checker=checker) + assert result["thresholds"] == [] + + +def test_build_host_info_threshold_null_warning_critical(): + from hbd.server.threshold import ThresholdConfig + tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0) + checker = MagicMock() + checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc} + host = _FakeHost() + result = _build_host_info(host, threshold_checker=checker) + assert result["thresholds"][0]["warning"] is None + assert result["thresholds"][0]["critical"] == 500.0 +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pytest tests/test_http_host_info.py -v +``` + +Expected: `ImportError` or `AttributeError` — `_build_host_info` does not exist yet. + +- [ ] **Step 3: Implement `_build_host_info` in `hbd/server/http.py`** + +Insert after `_mask_config_for_api` (around line 128, before `def serve(`): + +```python +def _build_host_info(host, threshold_checker=None): + """Assemble the info payload for GET /api/0/hosts/{hostname}/info.""" + hbc_version = None + hbc_type = None + latest_os = host.get_latest_plugin_data("os_info") + if latest_os: + _, os_data = latest_os + hbc_version = os_data.get("hbc_version") + hbc_type = os_data.get("hbc_type") + + last_packet = None + if host.connections: + last_packet = max(conn.lastbeat for conn in host.connections.values()) + + thresholds = None + if threshold_checker is not None: + raw = threshold_checker.get_thresholds_for_host(host.name) + thresholds = sorted( + [ + { + "metric": tc.metric_path, + "warning": tc.warning, + "critical": tc.critical, + "operator": tc.operator.value, + } + for tc in raw.values() + ], + key=lambda x: x["metric"], + ) + + return { + "owner": getattr(host, "owner", None), + "managers": list(getattr(host, "managers", [])), + "hbc_version": hbc_version, + "hbc_type": hbc_type, + "last_packet": last_packet, + "thresholds": thresholds, + } +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +pytest tests/test_http_host_info.py -v +``` + +Expected: all 11 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_http_host_info.py hbd/server/http.py +git commit -m "feat: add _build_host_info helper for host info endpoint" +``` + +--- + +### Task 2: `api_host_info` route handler + +**Files:** +- Modify: `hbd/server/http.py` + - Add `api_host_info` closure inside `serve()` (after `api_host_access_put`, around line 829) + - Register route (around line 1271) + +- [ ] **Step 1: Add `api_host_info` closure inside `serve()`** + +Insert after `api_host_access_put` (after line 829, before the comment `# User profile page`): + +```python + # ------------------------------------------------------------------------- + # Host info endpoint + # ------------------------------------------------------------------------- + + async def api_host_info(request): + """GET /api/0/hosts/{hostname}/info""" + user, err = _require_auth(request) + if err: + return err + hostname = request.match_info.get("hostname") + if hostname not in hbdclass.Host.hosts: + return web.json_response({"error": f"Host '{hostname}' not found"}, status=404) + host = hbdclass.Host.hosts[hostname] + if not _can_view_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) + return web.json_response(_build_host_info(host, threshold_checker=threshold_checker)) +``` + +- [ ] **Step 2: Register the route** + +In the route list (around line 1271, after the existing `/api/0/hosts/{hostname}/access` routes): + +```python + web.get("/api/0/hosts/{hostname}/info", api_host_info), +``` + +- [ ] **Step 3: Verify the full test suite still passes** + +```bash +pytest tests/ -q +``` + +Expected: all tests PASS (no regressions). + +- [ ] **Step 4: Smoke-test the endpoint manually** (if a dev server is running) + +```bash +curl -s http://localhost:50004/api/0/hosts//info | python3 -m json.tool +``` + +Expected: JSON with `owner`, `managers`, `hbc_version`, `hbc_type`, `last_packet`, `thresholds` keys. + +- [ ] **Step 5: Commit** + +```bash +git add hbd/server/http.py +git commit -m "feat: add GET /api/0/hosts/{hostname}/info endpoint" +``` + +--- + +### Task 3: Info section HTML and CSS in `plugins.html` + +**Files:** +- Modify: `hbd/server/templates/plugins.html` + +- [ ] **Step 1: Add CSS for the info section** + +In the `` tag around line 391 and insert before it): + +```css + /* ── Host info section ──────────────────────────────────────────────────── */ + .host-info-section { + padding: 12px 16px; + background: #fafafa; + border-bottom: 1px solid #e0e0e0; + font-size: 0.85em; + } + .info-meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 3px 14px; + margin-bottom: 10px; + } + .info-label { font-weight: 600; color: #555; white-space: nowrap; } + .info-value { color: #222; } + .info-thresholds-title { + font-weight: 600; + color: #555; + margin-bottom: 6px; + } + .info-note { color: #888; font-style: italic; } + .info-loading { color: #bbb; font-style: italic; } +``` + +- [ ] **Step 2: Add info section placeholder to each host card** + +Inside the host loop, at the very start of `.host-body` (before the `{% set plugin_order %}` line, around line 438): + +```html +
+
+
Loading…
+
+``` + +The existing `{% set plugin_order %}` line and everything after stays unchanged. Only add the two new lines between `
` and `{% set plugin_order %}`. + +- [ ] **Step 3: Verify the page still renders without JS errors** + +Start the dev server and open `/plugins` in a browser. Expand any host card — you should see the "Loading…" italic line above the plugin accordions (it will not be replaced yet, that comes in Task 4). + +- [ ] **Step 4: Commit** + +```bash +git add hbd/server/templates/plugins.html +git commit -m "feat: add host info section placeholder and CSS to plugins.html" +``` + +--- + +### Task 4: JS — `infoCache`, `fetchHostInfo`, `renderInfoSection` + +**Files:** +- Modify: `hbd/server/templates/plugins.html` (JS `