Compare commits

..

3 Commits

Author SHA1 Message Date
andreas ca5ef384a8 version 5.1.18
Release / release (push) Successful in 5s
2026-05-04 09:13:18 -04:00
andreas c93dbdc0f4 fix: settings thresholds show correct per-config metrics; misc hbc fixes
Settings page: pass threshold_checker to http.start so the Threshold
Configurations section has data. Use threshold_checker's already-parsed
ThresholdConfig objects instead of re-parsing the raw nested YAML.
Named (non-default) configs now display only their explicit overrides
via threshold_raw_configs, not the full merged set with defaults.

hbc/hbc_mini: send boot and shutdown messages on first connection only
to avoid duplicate packets when multiple servers are configured.
Replace print("Daemonizing...") with logging.info so output goes to
syslog in daemon mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:12:39 -04:00
andreas 3a546a1e5c feat: fetch-based Update/Delete buttons with toast notification on Host Overview
Replace href navigation with fetch() so the server response is captured
and displayed in a slide-up toast at the bottom of the page. Delete also
removes the host card from the DOM on success without a page reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:16:54 -04:00
8 changed files with 113 additions and 48 deletions
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
"""
__all__ = ["__version__"]
__version__ = "5.1.17"
__version__ = "5.1.18"
+3 -1
View File
@@ -474,6 +474,7 @@ async def cleanup(connections: List[AsyncConnection]):
logger.error(f"Error sending shutdown: {e}")
conn.close()
break # Only send shutdown on first connection to avoid duplicates
# Give messages time to send
await asyncio.sleep(0.5)
@@ -540,6 +541,7 @@ async def async_main(args, config):
boot_msg["acks"] = 0
for conn in connections:
await conn.sendto(boot_msg)
break # Only send message on first connection to avoid duplicates
if args.message and not args.daemon:
# Message-only mode
@@ -739,7 +741,7 @@ def main(argv=None):
# Daemonize if requested
if args.daemon:
print("Daemonizing...")
logging.info("Daemonizing...")
daemonize()
_reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
+1 -1
View File
@@ -890,7 +890,7 @@ async def start(
tmpl = env.get_template("settings.html")
body = tmpl.render(
title="Settings - Heartbeat",
sections=settings_mod.get_settings_sections(config),
sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker),
current_user=current_user.to_dict() if current_user else None,
active_page="settings",
)
+1
View File
@@ -255,6 +255,7 @@ async def _run_async(config, config_path=None):
config=config,
hbdclass=hbdclass,
tcss=None,
threshold_checker=threshold_checker,
verbose=config.get("verbose", False),
get_now=lambda: time.time(),
VER="",
+30 -37
View File
@@ -88,7 +88,7 @@ def _sanitize_channel(name, cfg):
# Public API
# ---------------------------------------------------------------------------
def get_settings_sections(config: dict) -> list:
def get_settings_sections(config: dict, threshold_checker=None) -> list:
"""Return ordered list of setting sections for the settings page.
Each section:
@@ -182,46 +182,39 @@ def get_settings_sections(config: dict) -> list:
})
# ---- Threshold configurations -----------------------------------------
def _parse_metric_row(metric_path, metric_cfg):
if not isinstance(metric_cfg, dict):
return None
def _tc_to_row(tc):
return {
"metric": metric_path,
"operator": metric_cfg.get("operator", ">"),
"warning": metric_cfg.get("warning"),
"critical": metric_cfg.get("critical"),
"hysteresis": metric_cfg.get("hysteresis"),
"count": metric_cfg.get("count", 1),
"enabled": metric_cfg.get("enabled", True),
"metric": tc.metric_path,
"operator": tc.operator.value,
"warning": tc.warning,
"critical": tc.critical,
"hysteresis": tc.hysteresis,
"count": tc.count,
"enabled": tc.enabled,
}
threshold_config_list = []
raw_tconfigs = config.get("threshold_configs") or {}
if raw_tconfigs:
for cfg_name, cfg_data in sorted(raw_tconfigs.items()):
if not isinstance(cfg_data, dict):
continue
metrics = [
r for r in (
_parse_metric_row(mp, mc)
for mp, mc in (cfg_data.get("thresholds") or {}).items()
) if r
]
threshold_config_list.append({
"name": cfg_name,
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
elif config.get("thresholds"):
metrics = [
r for r in (
_parse_metric_row(mp, mc)
for mp, mc in config["thresholds"].items()
) if r
]
threshold_config_list.append({
"name": "default",
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
if threshold_checker is not None:
if threshold_checker.threshold_configs:
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
# For the default config use the merged effective set;
# for named overrides use only the explicitly defined metrics
# (threshold_raw_configs) so inherited defaults are not repeated.
if cfg_name == "default":
display_metrics = cfg_metrics
else:
display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
metrics = sorted(
[_tc_to_row(tc) for tc in display_metrics.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
elif threshold_checker.thresholds:
metrics = sorted(
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": "default", "metrics": metrics})
# ---- Hosts summary ----------------------------------------------------
hosts_list = []
+72 -6
View File
@@ -152,6 +152,31 @@
}
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #323232;
color: #fff;
padding: 12px 22px;
border-radius: 6px;
font-size: 0.9em;
max-width: 480px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 9000;
white-space: pre-wrap;
}
#action-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#action-toast.error { background: #c62828; }
/* ── Host body ──────────────────────────────────────────────── */
.host-body {
@@ -401,12 +426,10 @@
{% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %}
<a class="host-action-btn update-btn"
href="/u?h={{ host.name }}"
onclick="event.stopPropagation()">Update</a>
<a class="host-action-btn delete-btn"
href="/d?h={{ host.name }}"
onclick="event.stopPropagation(); return confirm('Delete host {{ host.name }}?')">Delete</a>
<button class="host-action-btn update-btn"
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
<button class="host-action-btn delete-btn"
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
{% endif %}
</div>
</div>
@@ -1204,6 +1227,49 @@
fetchHostGlance(first.dataset.hostname);
}
});
// ── Host action helpers ──────────────────────────────────────
let _toastTimer = null;
function showToast(msg, isError) {
const t = document.getElementById('action-toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
}
async function hostAction(btn, url) {
btn.disabled = true;
try {
const res = await fetch(url);
const text = await res.text();
showToast(text, !res.ok);
} catch (e) {
showToast('Request failed: ' + e.message, true);
} finally {
btn.disabled = false;
}
}
async function hostDelete(btn, hostname) {
if (!confirm('Delete host ' + hostname + '?')) return;
btn.disabled = true;
try {
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
const text = await res.text();
showToast(text, !res.ok);
if (res.ok) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.remove();
}
} catch (e) {
showToast('Request failed: ' + e.message, true);
btn.disabled = false;
}
}
</script>
<div id="action-toast"></div>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hbd"
version = "5.1.17"
version = "5.1.18"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md"
requires-python = ">=3.11"
+4 -1
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh
__version__ = "5.1.17"
__version__ = "5.1.18"
# ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py)
@@ -1054,6 +1054,7 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
bmsg["msg"] = args.message
for c in connections:
await c.sendto(bmsg)
break
if args.message and not args.daemon:
await asyncio.sleep(0.3)
for c in connections:
@@ -1090,6 +1091,8 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
await conn.sendto({"shutdown": 1, "acks": conn.ackcount})
except Exception:
pass
break
for conn in connections:
conn.close()
await asyncio.sleep(0.3)
for plugin in plugins: