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__"] __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}") logger.error(f"Error sending shutdown: {e}")
conn.close() conn.close()
break # Only send shutdown on first connection to avoid duplicates
# Give messages time to send # Give messages time to send
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@@ -540,6 +541,7 @@ async def async_main(args, config):
boot_msg["acks"] = 0 boot_msg["acks"] = 0
for conn in connections: for conn in connections:
await conn.sendto(boot_msg) await conn.sendto(boot_msg)
break # Only send message on first connection to avoid duplicates
if args.message and not args.daemon: if args.message and not args.daemon:
# Message-only mode # Message-only mode
@@ -739,7 +741,7 @@ def main(argv=None):
# Daemonize if requested # Daemonize if requested
if args.daemon: if args.daemon:
print("Daemonizing...") logging.info("Daemonizing...")
daemonize() daemonize()
_reconfigure_logging_for_daemon(log_level) _reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}") 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") tmpl = env.get_template("settings.html")
body = tmpl.render( body = tmpl.render(
title="Settings - Heartbeat", 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, current_user=current_user.to_dict() if current_user else None,
active_page="settings", active_page="settings",
) )
+1
View File
@@ -255,6 +255,7 @@ async def _run_async(config, config_path=None):
config=config, config=config,
hbdclass=hbdclass, hbdclass=hbdclass,
tcss=None, tcss=None,
threshold_checker=threshold_checker,
verbose=config.get("verbose", False), verbose=config.get("verbose", False),
get_now=lambda: time.time(), get_now=lambda: time.time(),
VER="", VER="",
+30 -37
View File
@@ -88,7 +88,7 @@ def _sanitize_channel(name, cfg):
# Public API # 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. """Return ordered list of setting sections for the settings page.
Each section: Each section:
@@ -182,46 +182,39 @@ def get_settings_sections(config: dict) -> list:
}) })
# ---- Threshold configurations ----------------------------------------- # ---- Threshold configurations -----------------------------------------
def _parse_metric_row(metric_path, metric_cfg): def _tc_to_row(tc):
if not isinstance(metric_cfg, dict):
return None
return { return {
"metric": metric_path, "metric": tc.metric_path,
"operator": metric_cfg.get("operator", ">"), "operator": tc.operator.value,
"warning": metric_cfg.get("warning"), "warning": tc.warning,
"critical": metric_cfg.get("critical"), "critical": tc.critical,
"hysteresis": metric_cfg.get("hysteresis"), "hysteresis": tc.hysteresis,
"count": metric_cfg.get("count", 1), "count": tc.count,
"enabled": metric_cfg.get("enabled", True), "enabled": tc.enabled,
} }
threshold_config_list = [] threshold_config_list = []
raw_tconfigs = config.get("threshold_configs") or {} if threshold_checker is not None:
if raw_tconfigs: if threshold_checker.threshold_configs:
for cfg_name, cfg_data in sorted(raw_tconfigs.items()): for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
if not isinstance(cfg_data, dict): # For the default config use the merged effective set;
continue # for named overrides use only the explicitly defined metrics
metrics = [ # (threshold_raw_configs) so inherited defaults are not repeated.
r for r in ( if cfg_name == "default":
_parse_metric_row(mp, mc) display_metrics = cfg_metrics
for mp, mc in (cfg_data.get("thresholds") or {}).items() else:
) if r display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
] metrics = sorted(
threshold_config_list.append({ [_tc_to_row(tc) for tc in display_metrics.values()],
"name": cfg_name, key=lambda m: m["metric"],
"metrics": sorted(metrics, key=lambda m: m["metric"]), )
}) threshold_config_list.append({"name": cfg_name, "metrics": metrics})
elif config.get("thresholds"): elif threshold_checker.thresholds:
metrics = [ metrics = sorted(
r for r in ( [_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
_parse_metric_row(mp, mc) key=lambda m: m["metric"],
for mp, mc in config["thresholds"].items() )
) if r threshold_config_list.append({"name": "default", "metrics": metrics})
]
threshold_config_list.append({
"name": "default",
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
# ---- Hosts summary ---------------------------------------------------- # ---- Hosts summary ----------------------------------------------------
hosts_list = [] hosts_list = []
+72 -6
View File
@@ -152,6 +152,31 @@
} }
.host-action-btn.delete-btn:hover { background: #ffcdd2; } .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 ──────────────────────────────────────────────── */
.host-body { .host-body {
@@ -401,12 +426,10 @@
{% endif %} {% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span> <span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %} {% if host.is_owner %}
<a class="host-action-btn update-btn" <button class="host-action-btn update-btn"
href="/u?h={{ host.name }}" onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
onclick="event.stopPropagation()">Update</a> <button class="host-action-btn delete-btn"
<a class="host-action-btn delete-btn" onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
href="/d?h={{ host.name }}"
onclick="event.stopPropagation(); return confirm('Delete host {{ host.name }}?')">Delete</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -1204,6 +1227,49 @@
fetchHostGlance(first.dataset.hostname); 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> </script>
<div id="action-toast"></div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.1.17" version = "5.1.18"
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"
+4 -1
View File
@@ -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.1.17" __version__ = "5.1.18"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
@@ -1054,6 +1054,7 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
bmsg["msg"] = args.message bmsg["msg"] = args.message
for c in connections: for c in connections:
await c.sendto(bmsg) await c.sendto(bmsg)
break
if args.message and not args.daemon: if args.message and not args.daemon:
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
for c in connections: 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}) await conn.sendto({"shutdown": 1, "acks": conn.ackcount})
except Exception: except Exception:
pass pass
break
for conn in connections:
conn.close() conn.close()
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
for plugin in plugins: for plugin in plugins: