Files
heartbeat/docs/superpowers/plans/2026-05-10-host-overview-info-section.md
T
2026-05-10 08:09:47 -04:00

18 KiB

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:

"""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
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():

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
pytest tests/test_http_host_info.py -v

Expected: all 11 tests PASS.

  • Step 5: Commit
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):

    # -------------------------------------------------------------------------
    # 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):

            web.get("/api/0/hosts/{hostname}/info", api_host_info),
  • Step 3: Verify the full test suite still passes
pytest tests/ -q

Expected: all tests PASS (no regressions).

  • Step 4: Smoke-test the endpoint manually (if a dev server is running)
curl -s http://localhost:50004/api/0/hosts/<hostname>/info | python3 -m json.tool

Expected: JSON with owner, managers, hbc_version, hbc_type, last_packet, thresholds keys.

  • Step 5: Commit
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 <style> block (find the closing </style> tag around line 391 and insert before it):

    /* ── 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):

          <div class="host-body">
            <div class="host-info-section" id="info-{{ host.name }}">
              <div class="info-loading">Loading…</div>
            </div>

The existing {% set plugin_order %} line and everything after stays unchanged. Only add the two new lines between <div class="host-body"> 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
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 <script> block)

  • Step 1: Add infoCache constant

After the pluginCache declaration (after const pluginCache = {};, around line 489), add:

      // infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
      const infoCache = {};
  • Step 2: Add fetchHostInfo function

After the existing fetchPlugin function (around line 522, before fetchHostGlance), add:

      async function fetchHostInfo(hostname) {
        const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      }
  • Step 3: Add renderInfoSection function

After fetchHostInfo (before fetchHostGlance), add:

      function renderInfoSection(hostname, data) {
        const el = document.getElementById(`info-${hostname}`);
        if (!el) return;

        const owner    = data.owner ? escHtml(data.owner) : '—';
        const managers = data.managers && data.managers.length
          ? data.managers.map(escHtml).join(', ') : '—';
        const hbcVer  = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
        const hbcType = data.hbc_type    ? escHtml(String(data.hbc_type))    : '—';
        const lastPkt = data.last_packet
          ? new Date(data.last_packet * 1000).toLocaleString() : '—';

        let html = `<div class="info-meta">
          <span class="info-label">Owner</span><span class="info-value">${owner}</span>
          <span class="info-label">Managers</span><span class="info-value">${managers}</span>
          <span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
          <span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
          <span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
        </div>`;

        if (data.thresholds === null) {
          html += `<div class="info-note">Threshold alerting not configured.</div>`;
        } else if (data.thresholds.length === 0) {
          html += `<div class="info-note">No thresholds defined.</div>`;
        } else {
          html += `<div class="info-thresholds-title">Effective Thresholds</div>
            <table class="data-table"><thead><tr>
              <th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
            </tr></thead><tbody>`;
          for (const t of data.thresholds) {
            const w = t.warning  !== null && t.warning  !== undefined ? t.warning  : '—';
            const c = t.critical !== null && t.critical !== undefined ? t.critical : '—';
            html += `<tr>
              <td class="key">${escHtml(t.metric)}</td>
              <td>${escHtml(t.operator)}</td>
              <td>${w}</td>
              <td>${c}</td>
            </tr>`;
          }
          html += `</tbody></table>`;
        }

        el.innerHTML = html;
      }
  • Step 4: Commit
git add hbd/server/templates/plugins.html
git commit -m "feat: add fetchHostInfo and renderInfoSection JS functions"

Task 5: Wire fetchHostInfo into toggleHost

Files:

  • Modify: hbd/server/templates/plugins.html (the toggleHost function, around line 643)

  • Step 1: Replace toggleHost with the updated version

Find the existing toggleHost function:

      function toggleHost(hostname) {
        const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
        const wasCollapsed = card.classList.contains('collapsed');
        card.classList.toggle('collapsed');
        if (wasCollapsed && !pluginCache[hostname]) {
          fetchHostGlance(hostname);
        }
      }

Replace with:

      function toggleHost(hostname) {
        const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
        const wasCollapsed = card.classList.contains('collapsed');
        card.classList.toggle('collapsed');
        if (wasCollapsed) {
          if (!pluginCache[hostname]) {
            fetchHostGlance(hostname);
          }
          if (!infoCache[hostname]) {
            const infoEl = document.getElementById(`info-${hostname}`);
            if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
            fetchHostInfo(hostname).then(data => {
              infoCache[hostname] = data;
              renderInfoSection(hostname, data);
            }).catch(() => {
              const el = document.getElementById(`info-${hostname}`);
              if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
            });
          }
        }
      }
  • Step 2: Test in browser

Open /plugins, expand a host card. Verify:

  • The info section appears above the plugin accordions.

  • Owner, managers (or "—"), agent version, agent type, last packet render correctly.

  • Threshold table renders (or the appropriate "not configured" / "none defined" message).

  • Collapsing and re-expanding does not re-fetch (no second network request).

  • Step 3: Commit

git add hbd/server/templates/plugins.html
git commit -m "feat: fetch and render host info section on card expand"

Task 6: Remove hbc_version and hbc_type from renderOsInfoTable

Files:

  • Modify: hbd/server/templates/plugins.html (the renderOsInfoTable function, around line 794)

  • Step 1: Update renderOsInfoTable

Find the existing function:

      function renderOsInfoTable(d) {
        const ORDER = ['distro_pretty_name','system','release','version','machine',
                       'processor','architecture','node','python_version',
                       'python_implementation','hbc_version',
                       'distro_name','distro_version','distro_id','distro_version_id'];
        const shown = new Set(ORDER);
        const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];

Replace with:

      function renderOsInfoTable(d) {
        const ORDER = ['distro_pretty_name','system','release','version','machine',
                       'processor','architecture','node','python_version',
                       'python_implementation',
                       'distro_name','distro_version','distro_id','distro_version_id'];
        const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
        const shown = new Set(ORDER);
        const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
  • Step 2: Verify in browser

Expand a host card, then expand the "Os Info" accordion. Confirm:

  • hbc_version no longer appears in the os_info table.

  • hbc_type no longer appears in the os_info table.

  • Both values are shown correctly in the info section at the top.

  • Step 3: Run the full test suite

pytest tests/ -q

Expected: all tests PASS.

  • Step 4: Commit
git add hbd/server/templates/plugins.html
git commit -m "feat: move hbc_version and hbc_type out of os_info into host info section"