Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3401cc0dbb | |||
| ab0132a38d | |||
| 9e389736f8 | |||
| b64a2a9313 | |||
| a52744a448 | |||
| 5e2b04b811 | |||
| 8e07b09d7e | |||
| 653e018e4f | |||
| c7326da7d9 | |||
| 0426a75d8c | |||
| 539f25d877 |
@@ -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/<hostname>/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 `<style>` block (find the closing `</style>` 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
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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 `<script>` block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `infoCache` constant**
|
||||||
|
|
||||||
|
After the `pluginCache` declaration (after `const pluginCache = {};`, around line 489), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Host Overview Info Section
|
||||||
|
|
||||||
|
**Date:** 2026-05-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an always-visible info section to each host card on the Host Overview (`/plugins`) page. The section shows owner, managers, agent version/type, last packet timestamp, and the host's effective alert thresholds. The fields `hbc_version` and `hbc_type` are moved out of the `os_info` plugin accordion into this section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend: New API Endpoint
|
||||||
|
|
||||||
|
**Route:** `GET /api/0/hosts/{hostname}/info`
|
||||||
|
|
||||||
|
**Auth:** Same as other per-host endpoints (`_can_view_host`).
|
||||||
|
|
||||||
|
**Response schema:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "alice",
|
||||||
|
"managers": ["bob", "carol"],
|
||||||
|
"hbc_version": "5.3.0",
|
||||||
|
"hbc_type": "full",
|
||||||
|
"last_packet": 1746894000.0,
|
||||||
|
"thresholds": [
|
||||||
|
{
|
||||||
|
"metric": "cpu_monitor.cpu_percent",
|
||||||
|
"warning": 80.0,
|
||||||
|
"critical": 95.0,
|
||||||
|
"operator": ">"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field details:**
|
||||||
|
|
||||||
|
- `owner` — `host.owner`, or `null` if unset.
|
||||||
|
- `managers` — `host.managers` list (may be empty).
|
||||||
|
- `hbc_version` — from `host.get_latest_plugin_data("os_info")`, key `hbc_version`; `null` if no os_info data.
|
||||||
|
- `hbc_type` — same source, key `hbc_type`; `null` if unavailable.
|
||||||
|
- `last_packet` — `max(conn.lastbeat for conn in host.connections.values())`, or `null` if no connections.
|
||||||
|
- `thresholds` — list derived from `threshold_checker.get_thresholds_for_host(hostname)`, sorted by `metric` ascending. Each entry includes `metric`, `warning` (null if unset), `critical` (null if unset), `operator`. Returns `null` (not `[]`) if no `threshold_checker` is configured, so the frontend can distinguish "not configured" from "configured but empty".
|
||||||
|
|
||||||
|
**Location:** `hbd/server/http.py`, added alongside the other `api_host_*` functions. Registered as `web.get("/api/0/hosts/{hostname}/info", api_host_info)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend: Info Section
|
||||||
|
|
||||||
|
### HTML structure
|
||||||
|
|
||||||
|
Inserted as the first child of `.host-body`, before the plugin accordions. It is not a collapsible accordion — it is always visible when the host card is expanded.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="host-info-section" id="info-{hostname}">
|
||||||
|
<div class="loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch lifecycle
|
||||||
|
|
||||||
|
- Fetched once per host on the first expansion of the host card (same trigger as the glance/plugin data).
|
||||||
|
- Result cached in a new per-host `infoCache` object (parallel to `pluginCache`).
|
||||||
|
- On subsequent expansions the cached data is rendered immediately without a new request.
|
||||||
|
|
||||||
|
### Rendered layout
|
||||||
|
|
||||||
|
Two logical areas rendered client-side from the JSON:
|
||||||
|
|
||||||
|
**Meta row** — a CSS-grid or simple `<dl>` showing:
|
||||||
|
|
||||||
|
| Label | Value |
|
||||||
|
|---------------|------------------------------|
|
||||||
|
| Owner | alice (or "—" if null) |
|
||||||
|
| Managers | bob, carol (or "—" if empty) |
|
||||||
|
| Agent Version | 5.3.0 (or "—") |
|
||||||
|
| Agent Type | full (or "—") |
|
||||||
|
| Last Packet | localized datetime string (or "—") |
|
||||||
|
|
||||||
|
**Threshold table** — rendered with the existing `data-table` CSS class:
|
||||||
|
|
||||||
|
| Metric | Operator | Warning | Critical |
|
||||||
|
|--------|----------|---------|----------|
|
||||||
|
| cpu_monitor.cpu_percent | > | 80 | 95 |
|
||||||
|
| … | … | … | … |
|
||||||
|
|
||||||
|
- If `thresholds` is `null`: show "Threshold alerting not configured."
|
||||||
|
- If `thresholds` is `[]`: show "No thresholds defined."
|
||||||
|
- Numeric threshold values rendered as-is (no units); `null` warning/critical shown as "—".
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
New `.host-info-section` styles added in the `<style>` block of `plugins.html`. The section gets a subtle background (e.g. `#fafafa`) and a bottom border to separate it visually from the plugin accordions below. The meta row uses a two-column grid layout for compactness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes to `renderOsInfoTable()`
|
||||||
|
|
||||||
|
- Remove `hbc_version` from the `ORDER` array.
|
||||||
|
- Add `hbc_type` to the `SKIP_FIELDS` set (or the local `shown` set) so it is excluded from the os_info table.
|
||||||
|
|
||||||
|
Both fields will now appear only in the info section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
User expands host card
|
||||||
|
→ toggleHost()
|
||||||
|
→ fetchGlanceData(hostname) [existing, unchanged]
|
||||||
|
→ fetchInfoData(hostname) [new]
|
||||||
|
GET /api/0/hosts/{hostname}/info
|
||||||
|
→ renderInfoSection(hostname, data)
|
||||||
|
→ writes into #info-{hostname}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the info fetch fails (non-200), show a one-line error message in the info section ("Could not load host info.").
|
||||||
|
- If `hbc_version`/`hbc_type` are null (host has never sent os_info), display "—".
|
||||||
|
- If `last_packet` is null (no connections recorded), display "—".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Editing owner/managers from this section (covered by existing profile/access UI).
|
||||||
|
- Editing thresholds from this section.
|
||||||
|
- Monitors list (not shown — monitors are operational, not informational in this context).
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.1"
|
||||||
|
|||||||
+11
-3
@@ -79,9 +79,13 @@ THRESHOLD_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'memory_monitor': {
|
'memory_monitor': {
|
||||||
'percent': {
|
'memory_percent': {
|
||||||
'warning': 85.0,
|
'warning': 85.0,
|
||||||
'critical': 95.0
|
'critical': 95.0
|
||||||
|
},
|
||||||
|
'swap_percent': {
|
||||||
|
'warning': 40.0,
|
||||||
|
'critical': 75.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'disk_monitor': {
|
'disk_monitor': {
|
||||||
@@ -109,11 +113,15 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'pools': {
|
'pools': {
|
||||||
'*': {
|
'*': {
|
||||||
'status': {
|
'status': {
|
||||||
'warning': 1,
|
'warning': 1,
|
||||||
'critical': 2,
|
'critical': 2,
|
||||||
'operator': '>',
|
'operator': '>',
|
||||||
'hysteresis': 0.0,
|
'hysteresis': 0.0,
|
||||||
'display': 'ZFS pool {pool_name} is {health}'
|
'display': 'ZFS pool {pool_name} is {health}'
|
||||||
|
},
|
||||||
|
'capacity': {
|
||||||
|
'warning': 80.0,
|
||||||
|
'critical': 90.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,66 @@ def _mask_config_for_api(config) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
# Build reverse coverage: which metric paths suffix-match to each threshold.
|
||||||
|
# Mirrors the logic in ThresholdChecker._find_threshold.
|
||||||
|
coverage: dict = {}
|
||||||
|
for plugin_name, samples in host.plugin_data.items():
|
||||||
|
if not samples:
|
||||||
|
continue
|
||||||
|
_, pdata = samples[-1]
|
||||||
|
for field_name in pdata:
|
||||||
|
full_path = f"{plugin_name}.{field_name}"
|
||||||
|
if full_path in raw:
|
||||||
|
continue # exact match — the threshold IS this metric
|
||||||
|
parts = field_name.split("_")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
candidate = f"{plugin_name}." + "_".join(parts[i:])
|
||||||
|
if candidate in raw:
|
||||||
|
coverage.setdefault(candidate, []).append(full_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
thresholds = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
"covers": sorted(coverage.get(tc.metric_path, [])),
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -828,6 +888,23 @@ async def start(
|
|||||||
|
|
||||||
return web.json_response(host.access_dict())
|
return web.json_response(host.access_dict())
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# 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))
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# User profile page
|
# User profile page
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1269,6 +1346,7 @@ async def start(
|
|||||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||||
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
||||||
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
||||||
|
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||||
web.get("/api/0/alerts", api_all_alerts),
|
web.get("/api/0/alerts", api_all_alerts),
|
||||||
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
||||||
web.get("/c", cmd),
|
web.get("/c", cmd),
|
||||||
|
|||||||
@@ -388,6 +388,30 @@
|
|||||||
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
||||||
|
|
||||||
|
/* ── 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; }
|
||||||
|
.threshold-covers { font-size: 0.85em; color: #777; font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -436,6 +460,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-body">
|
<div class="host-body">
|
||||||
|
<div class="host-info-section" id="info-{{ host.name }}">
|
||||||
|
<div class="info-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
||||||
{% for plugin in plugin_order if plugin in host.plugins %}
|
{% for plugin in plugin_order if plugin in host.plugins %}
|
||||||
<div class="plugin-accordion collapsed"
|
<div class="plugin-accordion collapsed"
|
||||||
@@ -488,6 +515,9 @@
|
|||||||
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
||||||
const pluginCache = {};
|
const pluginCache = {};
|
||||||
|
|
||||||
|
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
||||||
|
const infoCache = {};
|
||||||
|
|
||||||
function setCache(hostname, pluginName, sample) {
|
function setCache(hostname, pluginName, sample) {
|
||||||
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
||||||
pluginCache[hostname][pluginName] = {
|
pluginCache[hostname][pluginName] = {
|
||||||
@@ -521,6 +551,61 @@
|
|||||||
return json.samples?.[0] ?? null;
|
return json.samples?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchHostInfo(hostname) {
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != null
|
||||||
|
? 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 ? escHtml(String(t.warning)) : '—';
|
||||||
|
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
|
||||||
|
let metricCell = escHtml(t.metric);
|
||||||
|
if (t.covers && t.covers.length > 0) {
|
||||||
|
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
|
||||||
|
}
|
||||||
|
html += `<tr>
|
||||||
|
<td class="key">${metricCell}</td>
|
||||||
|
<td>${escHtml(t.operator)}</td>
|
||||||
|
<td>${w}</td>
|
||||||
|
<td>${c}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHostGlance(hostname) {
|
async function fetchHostGlance(hostname) {
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
||||||
@@ -644,8 +729,21 @@
|
|||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const wasCollapsed = card.classList.contains('collapsed');
|
const wasCollapsed = card.classList.contains('collapsed');
|
||||||
card.classList.toggle('collapsed');
|
card.classList.toggle('collapsed');
|
||||||
if (wasCollapsed && !pluginCache[hostname]) {
|
if (wasCollapsed) {
|
||||||
fetchHostGlance(hostname);
|
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>';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,10 +892,11 @@
|
|||||||
function renderOsInfoTable(d) {
|
function renderOsInfoTable(d) {
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
'processor','architecture','node','python_version',
|
'processor','architecture','node','python_version',
|
||||||
'python_implementation','hbc_version',
|
'python_implementation',
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||||
const shown = new Set(ORDER);
|
const shown = new Set(ORDER);
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||||
|
|
||||||
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.0"
|
version = "5.3.1"
|
||||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.1"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""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, plugin_data=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = managers or []
|
||||||
|
self.connections = connections or {}
|
||||||
|
self._os_data = os_data
|
||||||
|
self.plugin_data = plugin_data or {}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_nagios_operator_serialized():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["operator"] == "nagios"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_suffix_matched_metrics():
|
||||||
|
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost(
|
||||||
|
connections={},
|
||||||
|
os_data=None,
|
||||||
|
)
|
||||||
|
# Simulate plugin_data with both percent and swap_percent fields
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {
|
||||||
|
"percent": 80.0,
|
||||||
|
"swap_percent": 25.0,
|
||||||
|
"available_mb": 2000,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["metric"] == "memory_monitor.percent"
|
||||||
|
assert t["covers"] == ["memory_monitor.swap_percent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_empty_when_exact_matches_only():
|
||||||
|
"""No covers when all plugin fields match their threshold exactly."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["covers"] == []
|
||||||
Reference in New Issue
Block a user