Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e5bafd26c | |||
| 817ae064af | |||
| a00282913b | |||
| d699a29fa9 | |||
| 4ce7eacfdd | |||
| 1cefc2676e | |||
| 668a135e53 | |||
| 59e256a042 | |||
| 708508157f |
@@ -8,7 +8,7 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
|||||||
|
|
||||||
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
||||||
- Maintain host state and detect up/down transitions ✅
|
- Maintain host state and detect up/down transitions ✅
|
||||||
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
- Queue DNS updates via `nsupdate` and run them in an asyncio background task ✅
|
||||||
- WebSocket API for live updates (hosts & messages) ✅
|
- WebSocket API for live updates (hosts & messages) ✅
|
||||||
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||||
- **User management & access control** ✅
|
- **User management & access control** ✅
|
||||||
@@ -398,6 +398,7 @@ hosts:
|
|||||||
owner: alice
|
owner: alice
|
||||||
managers: [bob]
|
managers: [bob]
|
||||||
monitors: [carol]
|
monitors: [carol]
|
||||||
|
dyndns: true # update DNS record when IP changes
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -645,7 +646,7 @@ Set breakpoints in modules such as `hbd/server/udp.py`, `hbd/server/dns.py`, or
|
|||||||
- `logfile`: path to log file
|
- `logfile`: path to log file
|
||||||
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||||
- `interval` / `grace`: heartbeat timing configuration
|
- `interval` / `grace`: heartbeat timing configuration
|
||||||
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
- `dyndomains`: list of DNS domains to update via `nsupdate` for hosts with `dyndns` set
|
||||||
- `nsupdate_bin`: path to nsupdate binary
|
- `nsupdate_bin`: path to nsupdate binary
|
||||||
- `ws_port`: port for plain WebSocket connections (default: 50005)
|
- `ws_port`: port for plain WebSocket connections (default: 50005)
|
||||||
- `wss_port`: port for secure WebSocket (WSS) connections (default: none).
|
- `wss_port`: port for secure WebSocket (WSS) connections (default: none).
|
||||||
@@ -666,6 +667,9 @@ dyndomains:
|
|||||||
- example.com
|
- example.com
|
||||||
nsupdate_bin: /usr/bin/nsupdate
|
nsupdate_bin: /usr/bin/nsupdate
|
||||||
pushsrv: pushover
|
pushsrv: pushover
|
||||||
|
hosts:
|
||||||
|
myhost:
|
||||||
|
dyndns: true # update DNS when this host's IP changes
|
||||||
```
|
```
|
||||||
|
|
||||||
> Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
|
> Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
|
||||||
@@ -769,10 +773,3 @@ Contributions welcome! Please:
|
|||||||
This repository is licensed under the MIT license. See `LICENSE` for details.
|
This repository is licensed under the MIT license. See `LICENSE` for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
If you'd like, I can also:
|
|
||||||
|
|
||||||
- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁
|
|
||||||
- add a `CONTRIBUTING.md` template for PRs and code style 💬
|
|
||||||
|
|
||||||
Which one should I do next? ✨
|
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.2"
|
__version__ = "5.3.4"
|
||||||
|
|||||||
+9
-28
@@ -39,8 +39,6 @@ SERVER_DEFAULTS = {
|
|||||||
|
|
||||||
# Host management
|
# Host management
|
||||||
"hosts": {}, # Unified host definitions
|
"hosts": {}, # Unified host definitions
|
||||||
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
|
||||||
"drophosts": [], # Hosts to ignore
|
|
||||||
"dyndomains": ["wrede.org"],
|
"dyndomains": ["wrede.org"],
|
||||||
|
|
||||||
# DNS updates
|
# DNS updates
|
||||||
@@ -249,7 +247,7 @@ def get_watchhosts(config):
|
|||||||
"""Extract watched hostnames from config (hosts with watch: true).
|
"""Extract watched hostnames from config (hosts with watch: true).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of hostnames to watch
|
# List of hostnames to watch
|
||||||
"""
|
"""
|
||||||
watchhosts = []
|
watchhosts = []
|
||||||
hosts_config = config.get("hosts", {})
|
hosts_config = config.get("hosts", {})
|
||||||
@@ -261,31 +259,14 @@ def get_watchhosts(config):
|
|||||||
|
|
||||||
|
|
||||||
def get_dyndnshosts(config):
|
def get_dyndnshosts(config):
|
||||||
"""Extract dyndnshosts from config, supporting both new and legacy formats.
|
"""Return hostnames that have a dyndns setting in the hosts section."""
|
||||||
|
hosts_config = config.get("hosts", {})
|
||||||
Args:
|
if not isinstance(hosts_config, dict):
|
||||||
config: Configuration dictionary
|
return []
|
||||||
|
return [
|
||||||
Returns:
|
name for name, attrs in hosts_config.items()
|
||||||
List of hostnames with dynamic DNS
|
if isinstance(attrs, dict) and attrs.get("dyndns")
|
||||||
"""
|
]
|
||||||
dyndnshosts = []
|
|
||||||
|
|
||||||
# New format: hosts section with dyndns attribute
|
|
||||||
if "hosts" in config:
|
|
||||||
hosts_config = config["hosts"]
|
|
||||||
if isinstance(hosts_config, dict):
|
|
||||||
for host_name, host_attrs in hosts_config.items():
|
|
||||||
if isinstance(host_attrs, dict) and host_attrs.get("dyndns", False):
|
|
||||||
dyndnshosts.append(host_name)
|
|
||||||
|
|
||||||
# Legacy format: dyndnshosts list/set
|
|
||||||
if "dyndnshosts" in config:
|
|
||||||
legacy_dyndnshosts = config.get("dyndnshosts", [])
|
|
||||||
if isinstance(legacy_dyndnshosts, (list, set)):
|
|
||||||
dyndnshosts.extend(legacy_dyndnshosts)
|
|
||||||
|
|
||||||
return list(set(dyndnshosts)) # Remove duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def get_host_config(config, hostname):
|
def get_host_config(config, hostname):
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ _SERVER_KEYS = [
|
|||||||
"interval", "grace", "base_url", "threshold_renotify_interval",
|
"interval", "grace", "base_url", "threshold_renotify_interval",
|
||||||
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
||||||
"journal_max_size", "journal_max_backups", "default_owner",
|
"journal_max_size", "journal_max_backups", "default_owner",
|
||||||
|
"default_threshold_config",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Top-level keys managed by the 'dns' logical section
|
# Top-level keys managed by the 'dns' logical section
|
||||||
_DNS_KEYS = ["nsupdate_bin", "dyndomains", "dyndnshosts", "drophosts"]
|
_DNS_KEYS = ["nsupdate_bin", "rndc_key", "dyndomains"]
|
||||||
|
|
||||||
|
|
||||||
def read_roundtrip(path: str):
|
def read_roundtrip(path: str):
|
||||||
|
|||||||
+18
-15
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
from subprocess import Popen, PIPE, STDOUT
|
from subprocess import Popen, PIPE, STDOUT
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_nsupdate_payload(
|
def create_nsupdate_payload(
|
||||||
@@ -123,7 +126,6 @@ async def dns_update_worker(
|
|||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = f"changed address to {addr}"
|
|
||||||
for dyndomain in cfg.get("dyndomains", []):
|
for dyndomain in cfg.get("dyndomains", []):
|
||||||
err = await loop.run_in_executor(
|
err = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -135,28 +137,29 @@ async def dns_update_worker(
|
|||||||
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
m += f", DNS update failed: {err}"
|
m = f"DNS update failed for {addr} ({dyndomain}): {err}"
|
||||||
logger.error("DNS update failed for %s: %s", name, err)
|
logger.error("DNS update failed for %s: %s", name, err)
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "ERROR", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
m += ", DNS updated."
|
m = f"DNS updated {name}.dy.{dyndomain} → {addr}"
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "INFO", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not cfg.get("dyndomains"):
|
||||||
|
logger.warning("DNS update triggered for %s but no dyndomains configured", name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dnsq.task_done()
|
dnsq.task_done()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, name, m)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def start_dns_worker(
|
def start_dns_worker(
|
||||||
hbdclass,
|
hbdclass,
|
||||||
|
|||||||
+98
-10
@@ -25,6 +25,74 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
|
||||||
|
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
||||||
|
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
|
||||||
|
|
||||||
|
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
|
||||||
|
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for config_name, metrics in form_data.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
thresholds = {}
|
||||||
|
for metric_path, values in metrics.items():
|
||||||
|
_insert_threshold_metric(thresholds, metric_path, values)
|
||||||
|
result[config_name] = {"thresholds": thresholds}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -> None:
|
||||||
|
"""Insert a single metric into the nested threshold YAML structure."""
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = {}
|
||||||
|
op = values.get("operator", ">")
|
||||||
|
if op and op != ">":
|
||||||
|
cfg["operator"] = op
|
||||||
|
|
||||||
|
for key, cast in (("warning", float), ("critical", float), ("hysteresis", float)):
|
||||||
|
v = values.get(key)
|
||||||
|
if v is not None:
|
||||||
|
try:
|
||||||
|
cfg[key] = cast(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
count = values.get("count")
|
||||||
|
if count is not None:
|
||||||
|
try:
|
||||||
|
cfg["count"] = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
display = values.get("display", "")
|
||||||
|
if display:
|
||||||
|
cfg["display"] = display
|
||||||
|
|
||||||
|
if not values.get("enabled", True):
|
||||||
|
cfg["enabled"] = False
|
||||||
|
|
||||||
|
parts = metric_path.split(".", 2)
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
# e.g. "rtt"
|
||||||
|
thresholds[metric_path] = cfg
|
||||||
|
elif len(parts) == 2:
|
||||||
|
plugin, metric = parts
|
||||||
|
thresholds.setdefault(plugin, {})[metric] = cfg
|
||||||
|
else:
|
||||||
|
plugin, intermediate, leaf = parts
|
||||||
|
thresholds.setdefault(plugin, {})
|
||||||
|
if plugin == "disk_monitor":
|
||||||
|
thresholds[plugin].setdefault("partitions", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
elif plugin == "zfs_monitor":
|
||||||
|
thresholds[plugin].setdefault("pools", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
else:
|
||||||
|
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
|
||||||
def _render_template(html_str: str, **context) -> str:
|
def _render_template(html_str: str, **context) -> str:
|
||||||
tmpl = jinja2.Template(html_str)
|
tmpl = jinja2.Template(html_str)
|
||||||
return tmpl.render(**context)
|
return tmpl.render(**context)
|
||||||
@@ -196,6 +264,7 @@ async def start(
|
|||||||
get_now=None,
|
get_now=None,
|
||||||
VER="",
|
VER="",
|
||||||
threshold_checker=None,
|
threshold_checker=None,
|
||||||
|
reload_callback=None,
|
||||||
):
|
):
|
||||||
"""Start an aiohttp web server and block until cancelled.
|
"""Start an aiohttp web server and block until cancelled.
|
||||||
|
|
||||||
@@ -1228,10 +1297,17 @@ async def start(
|
|||||||
attrs.pop("client_secret", None)
|
attrs.pop("client_secret", None)
|
||||||
data["oauth"] = new_oauth
|
data["oauth"] = new_oauth
|
||||||
|
|
||||||
for section in ("notification_channels", "thresholds", "dns"):
|
for section in ("notification_channels", "dns"):
|
||||||
if section in payload:
|
if section in payload:
|
||||||
configio_mod.apply_yaml_section(data, section, payload[section])
|
configio_mod.apply_yaml_section(data, section, payload[section])
|
||||||
|
|
||||||
|
if "thresholds" in payload:
|
||||||
|
tc = payload["thresholds"]
|
||||||
|
if isinstance(tc, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
|
elif isinstance(tc, dict):
|
||||||
|
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
||||||
|
|
||||||
if "hosts" in payload:
|
if "hosts" in payload:
|
||||||
h = payload["hosts"]
|
h = payload["hosts"]
|
||||||
if isinstance(h, dict):
|
if isinstance(h, dict):
|
||||||
@@ -1244,9 +1320,11 @@ async def start(
|
|||||||
logger.error("Config write failed: %s", exc)
|
logger.error("Config write failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1275,9 +1353,11 @@ async def start(
|
|||||||
logger.error("Rollback failed: %s", exc)
|
logger.error("Rollback failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1399,7 +1479,9 @@ async def start(
|
|||||||
logger.error("Channel create failed: %s", exc)
|
logger.error("Channel create failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
return web.json_response({"ok": True, "name": name})
|
return web.json_response({"ok": True, "name": name})
|
||||||
|
|
||||||
@@ -1465,7 +1547,9 @@ async def start(
|
|||||||
logger.error("Channel update failed: %s", exc)
|
logger.error("Channel update failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1497,7 +1581,9 @@ async def start(
|
|||||||
logger.error("Channel delete failed: %s", exc)
|
logger.error("Channel delete failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1556,9 +1642,11 @@ async def start(
|
|||||||
logger.error("User self-update failed: %s", exc)
|
logger.error("User self-update failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|||||||
+5
-9
@@ -78,9 +78,7 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
True if reload succeeded, False otherwise
|
True if reload succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Starting configuration reload...")
|
logger.info("Starting configuration reload...")
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
# Reload config file
|
# Reload config file
|
||||||
new_config = await config_obj.reload(config_path)
|
new_config = await config_obj.reload(config_path)
|
||||||
@@ -115,13 +113,11 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
# These are reloadable and effective immediately:
|
# These are reloadable and effective immediately:
|
||||||
# - notification_channels
|
# - notification_channels
|
||||||
# - threshold_configs
|
# - threshold_configs
|
||||||
# - hosts (watchhosts, dyndnshosts, notification_channels)
|
# - hosts (watchhosts, dyndns, notification_channels)
|
||||||
# - grace period (used on next heartbeat)
|
# - grace period (used on next heartbeat)
|
||||||
# - debug/verbose flags (used on next message)
|
# - debug/verbose flags (used on next message)
|
||||||
|
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Configuration reload completed successfully")
|
logger.info("Configuration reload completed successfully")
|
||||||
logger.info("=" * 60)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -246,6 +242,9 @@ async def _run_async(config, config_path=None):
|
|||||||
# upgrade or config change between runs).
|
# upgrade or config change between runs).
|
||||||
threshold_checker.purge_stale_alerts(hbdclass)
|
threshold_checker.purge_stale_alerts(hbdclass)
|
||||||
|
|
||||||
|
async def _http_reload_callback():
|
||||||
|
await reload_configuration(config, config_path, components)
|
||||||
|
|
||||||
# HTTP server (asyncio-based via aiohttp)
|
# HTTP server (asyncio-based via aiohttp)
|
||||||
try:
|
try:
|
||||||
http_task = asyncio.create_task(
|
http_task = asyncio.create_task(
|
||||||
@@ -259,6 +258,7 @@ async def _run_async(config, config_path=None):
|
|||||||
verbose=config.get("verbose", False),
|
verbose=config.get("verbose", False),
|
||||||
get_now=lambda: time.time(),
|
get_now=lambda: time.time(),
|
||||||
VER="",
|
VER="",
|
||||||
|
reload_callback=_http_reload_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -422,7 +422,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
pickfile = config.get("pickfile", "hbd.pickle")
|
pickfile = config.get("pickfile", "hbd.pickle")
|
||||||
dyndnshosts = config_mod.get_dyndnshosts(config)
|
dyndnshosts = config_mod.get_dyndnshosts(config)
|
||||||
watchhosts = config_mod.get_watchhosts(config)
|
watchhosts = config_mod.get_watchhosts(config)
|
||||||
drophosts = config.get("drophosts", [])
|
|
||||||
if 1 and os.path.exists(pickfile):
|
if 1 and os.path.exists(pickfile):
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("opening pickls %s", pickfile)
|
logger.info("opening pickls %s", pickfile)
|
||||||
@@ -448,9 +447,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
hbdclass.Host.hosts[h].apply_access(
|
hbdclass.Host.hosts[h].apply_access(
|
||||||
access["owner"], access["managers"], access["monitors"]
|
access["owner"], access["managers"], access["monitors"]
|
||||||
)
|
)
|
||||||
for h in drophosts:
|
|
||||||
if h in hbdclass.Host.hosts:
|
|
||||||
del hbdclass.Host.hosts[h]
|
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"hysteresis": tc.hysteresis,
|
"hysteresis": tc.hysteresis,
|
||||||
"count": tc.count,
|
"count": tc.count,
|
||||||
"enabled": tc.enabled,
|
"enabled": tc.enabled,
|
||||||
|
"display": tc.display or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold_config_list = []
|
threshold_config_list = []
|
||||||
@@ -448,12 +449,12 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "thresholds",
|
"id": "thresholds",
|
||||||
"title": "Threshold Configurations",
|
"title": "Threshold Configurations",
|
||||||
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "thresholds",
|
||||||
"api_section": "thresholds",
|
"api_section": "thresholds",
|
||||||
"threshold_configs": threshold_config_list,
|
"threshold_configs": threshold_config_list,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_threshold_config", "Default config", "text",
|
field("default_threshold_config", "Default config", "text",
|
||||||
"Threshold config used for hosts with no explicit mapping."),
|
"Threshold config used for hosts with no explicit mapping.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,6 +125,23 @@
|
|||||||
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pending config publish button */
|
||||||
|
.nav-publish-btn {
|
||||||
|
background: #e65100;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.nav-publish-btn:hover { background: #bf360c; }
|
||||||
|
.nav-publish-btn:disabled { opacity: 0.7; cursor: default; }
|
||||||
|
|
||||||
/* Swiss railway clock — nav */
|
/* Swiss railway clock — nav */
|
||||||
.nav-pie {
|
.nav-pie {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -201,6 +201,43 @@
|
|||||||
.log-recover .log-level { color: #2a7a2a; }
|
.log-recover .log-level { color: #2a7a2a; }
|
||||||
.log-info .log-level { color: #555; }
|
.log-info .log-level { color: #555; }
|
||||||
|
|
||||||
|
.log-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-section-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"],
|
||||||
|
.log-filter-bar select {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"] { width: 110px; }
|
||||||
|
|
||||||
/* Modal for connection status messages */
|
/* Modal for connection status messages */
|
||||||
.connection-modal {
|
.connection-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -445,6 +482,22 @@
|
|||||||
updateRowAlert(name_idx[data.name], data);
|
updateRowAlert(name_idx[data.name], data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLogFilters() {
|
||||||
|
var hostFilter = document.getElementById('filter-host').value.toLowerCase().trim();
|
||||||
|
var levelFilter = document.getElementById('filter-level').value;
|
||||||
|
var msgFilter = document.getElementById('filter-msg').value.toLowerCase().trim();
|
||||||
|
document.querySelectorAll('#messages .log-entry').forEach(function(entry) {
|
||||||
|
var show = true;
|
||||||
|
if (hostFilter && !(entry.dataset.host || '').toLowerCase().includes(hostFilter)) show = false;
|
||||||
|
if (levelFilter && entry.dataset.level !== levelFilter) show = false;
|
||||||
|
if (msgFilter) {
|
||||||
|
var msgEl = entry.querySelector('.log-msg');
|
||||||
|
if (!msgEl || !msgEl.textContent.toLowerCase().includes(msgFilter)) show = false;
|
||||||
|
}
|
||||||
|
entry.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function WS_Connect() {
|
function WS_Connect() {
|
||||||
if ("WebSocket" in window) {
|
if ("WebSocket" in window) {
|
||||||
//N.B: subprotocol field causes chrome to error 1006
|
//N.B: subprotocol field causes chrome to error 1006
|
||||||
@@ -479,7 +532,8 @@
|
|||||||
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
||||||
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||||
var lvl = (msg.level || "INFO").toLowerCase();
|
var lvl = (msg.level || "INFO").toLowerCase();
|
||||||
var html = '<div class="log-entry log-' + lvl + '">';
|
var hostVal = msg.host || '';
|
||||||
|
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '"') + '">';
|
||||||
html += '<span class="log-ts">' + ts_str + '</span>';
|
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||||
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
||||||
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
||||||
@@ -487,6 +541,7 @@
|
|||||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
msgs.insertAdjacentHTML("afterbegin", html);
|
msgs.insertAdjacentHTML("afterbegin", html);
|
||||||
|
applyLogFilters();
|
||||||
}
|
}
|
||||||
cnt++;
|
cnt++;
|
||||||
};
|
};
|
||||||
@@ -575,7 +630,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-section">
|
<div class="log-section">
|
||||||
<h2>Log of Events</h2>
|
<div class="log-section-header">
|
||||||
|
<span class="log-section-title">Log of Events</span>
|
||||||
|
<div class="log-filter-bar">
|
||||||
|
<input type="text" id="filter-host" placeholder="Host…" title="Filter by host" />
|
||||||
|
<select id="filter-level" title="Filter by level">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="info">INFO</option>
|
||||||
|
<option value="warning">WARNING</option>
|
||||||
|
<option value="critical">CRITICAL</option>
|
||||||
|
<option value="recover">RECOVER</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,6 +659,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
setup();
|
setup();
|
||||||
|
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
|
||||||
|
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
|
||||||
|
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if current_user and current_user.admin %}
|
||||||
|
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">⚠ Publish Config</button>
|
||||||
|
{% endif %}
|
||||||
<div class="nav-pie" title="Host alert status">
|
<div class="nav-pie" title="Host alert status">
|
||||||
<canvas id="alert-pie" width="44" height="44"></canvas>
|
<canvas id="alert-pie" width="44" height="44"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,5 +95,40 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateAlertPie();
|
updateAlertPie();
|
||||||
setInterval(updateAlertPie, 30000);
|
setInterval(updateAlertPie, 30000);
|
||||||
|
navCheckPendingConfig();
|
||||||
|
window.addEventListener('storage', navCheckPendingConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function navCheckPendingConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.style.display = localStorage.getItem('hbd_pending_config') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navPublishConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
var pending = localStorage.getItem('hbd_pending_config');
|
||||||
|
if (!pending) return;
|
||||||
|
var staged;
|
||||||
|
try { staged = JSON.parse(pending); } catch(e) { return; }
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/0/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: pending
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
var err = await resp.json().catch(function() { return {}; });
|
||||||
|
alert('Error: ' + (err.error || resp.statusText));
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Network error: ' + e.message);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
html, body { overflow: visible; }
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||||
@@ -298,8 +298,6 @@
|
|||||||
|
|
||||||
/* ---- Editable inputs ---- */
|
/* ---- Editable inputs ---- */
|
||||||
.field-input {
|
.field-input {
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -367,7 +365,7 @@
|
|||||||
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
||||||
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
||||||
.crud-table tbody tr:last-child td { border-bottom: none; }
|
.crud-table tbody tr:last-child td { border-bottom: none; }
|
||||||
.crud-table .field-input { max-width: none; }
|
.crud-table .field-input { width: 100%; }
|
||||||
|
|
||||||
/* ---- Rollback modal ---- */
|
/* ---- Rollback modal ---- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
@@ -382,9 +380,88 @@
|
|||||||
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
||||||
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
||||||
.backup-row:last-child { border-bottom: none; }
|
.backup-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ---- Threshold config cards ---- */
|
||||||
|
.thresh-cfg-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thresh-cfg-header {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.thresh-cfg-name-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
.thresh-metric-table { width: 100%; }
|
||||||
|
.thresh-metric-table th { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- Multi-picker ---- */
|
||||||
|
.mpick-wrapper { display: block; }
|
||||||
|
.mpick-display {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
|
||||||
|
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
|
||||||
|
background: #fff; user-select: none; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
|
||||||
|
.mpick-tag {
|
||||||
|
padding: 1px 6px; background: #e8eaf6; color: #283593;
|
||||||
|
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
|
||||||
|
.mpick-panel {
|
||||||
|
position: fixed; background: #fff; border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
|
||||||
|
z-index: 2000; width: 360px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-panel-header {
|
||||||
|
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
|
||||||
|
border-bottom: 1px solid #eee; display: flex;
|
||||||
|
justify-content: space-between; align-items: center; background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.mpick-panel-body { display: flex; }
|
||||||
|
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
|
||||||
|
.mpick-col-header {
|
||||||
|
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
|
||||||
|
border-bottom: 1px solid #f0f0f0; background: #fafafa;
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||||||
|
.mpick-item {
|
||||||
|
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||||||
|
}
|
||||||
|
.mpick-item:last-child { border-bottom: none; }
|
||||||
|
.mpick-item-avail:hover { background: #e8f5e9; }
|
||||||
|
.mpick-item-sel:hover { background: #fce4ec; }
|
||||||
|
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
|
||||||
|
.mpick-item:hover .mpick-arrow { opacity: 1; }
|
||||||
|
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
|
||||||
|
.mpick-item-sel .mpick-arrow { color: #c62828; }
|
||||||
|
.mpick-panel-footer {
|
||||||
|
padding: 6px 10px; border-top: 1px solid #eee;
|
||||||
|
display: flex; justify-content: flex-end; background: #f8f8f8;
|
||||||
|
}
|
||||||
|
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
{%- macro mpick(all_items, sel, cls) -%}
|
||||||
|
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
|
||||||
|
{%- endmacro %}
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -501,11 +578,7 @@
|
|||||||
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
||||||
<td style="min-width:140px">
|
<td style="min-width:140px">
|
||||||
<select class="field-input user-ch-select" multiple size="{{ [all_channel_names|length, 4]|min }}" style="min-width:130px">
|
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
|
||||||
{% for ch in all_channel_names %}
|
|
||||||
<option value="{{ ch | e }}" {% if ch in u.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
||||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
@@ -576,34 +649,10 @@
|
|||||||
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
||||||
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
||||||
<td>
|
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
||||||
<select class="field-input host-managers" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
|
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
||||||
{% for u in all_usernames %}
|
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
||||||
<option value="{{ u | e }}" {% if u in h.managers %}selected{% endif %}>{{ u | e }}</option>
|
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select class="field-input host-monitors" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
|
|
||||||
{% for u in all_usernames %}
|
|
||||||
<option value="{{ u | e }}" {% if u in h.monitors %}selected{% endif %}>{{ u | e }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select class="field-input host-tc" multiple size="{{ [all_threshold_configs|length, 4]|min or 2 }}">
|
|
||||||
{% for tc in all_threshold_configs %}
|
|
||||||
<option value="{{ tc | e }}" {% if tc in h.threshold_configs %}selected{% endif %}>{{ tc | e }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select class="field-input host-channels" multiple size="{{ [all_channel_names|length, 4]|min or 2 }}">
|
|
||||||
{% for ch in all_channel_names %}
|
|
||||||
<option value="{{ ch | e }}" {% if ch in h.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -661,6 +710,84 @@
|
|||||||
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
|
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ---- Threshold configurations (form-based) ---- #}
|
||||||
|
{% elif section.section_mode == 'thresholds' %}
|
||||||
|
{% for f in section.fields %}
|
||||||
|
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||||||
|
<div class="field-label">{{ f.label }}</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<input type="text" class="field-input thresh-default-config"
|
||||||
|
value="{{ f.raw if f.raw is not none else '' }}"
|
||||||
|
placeholder="default"
|
||||||
|
list="thresh-cfg-names-{{ section.id }}">
|
||||||
|
<datalist id="thresh-cfg-names-{{ section.id }}">
|
||||||
|
{% for tc in section.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
|
||||||
|
{% for tc in section.threshold_configs %}
|
||||||
|
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
|
||||||
|
<div class="thresh-cfg-header">
|
||||||
|
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
|
||||||
|
{% if tc.name != 'default' %}
|
||||||
|
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="crud-table thresh-metric-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Metric path</th><th>Op</th>
|
||||||
|
<th>Warning</th><th>Critical</th>
|
||||||
|
<th>Hysteresis</th><th>Count</th>
|
||||||
|
<th style="max-width:160px">Display</th>
|
||||||
|
<th>En</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in tc.metrics %}
|
||||||
|
<tr data-metric-row="true" data-metric-path="{{ m.metric | e }}">
|
||||||
|
<td style="font-family:monospace;font-size:.85em;white-space:nowrap">{{ m.metric | e }}</td>
|
||||||
|
<td>
|
||||||
|
<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">
|
||||||
|
{% for op in ['>', '>=', '<', '<=', '==', '!=', 'nagios'] %}
|
||||||
|
<option value="{{ op }}" {% if m.operator == op %}selected{% endif %}>{{ op }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"
|
||||||
|
value="{{ m.warning if m.warning is not none else '' }}"
|
||||||
|
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||||||
|
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"
|
||||||
|
value="{{ m.critical if m.critical is not none else '' }}"
|
||||||
|
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||||||
|
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px"
|
||||||
|
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
|
||||||
|
value="{{ m.count if m.count is not none else 1 }}"></td>
|
||||||
|
<td><input type="text" class="field-input thresh-display" style="width:150px"
|
||||||
|
value="{{ m.display | e }}" placeholder="(default)"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
|
||||||
|
{% if m.enabled %}checked{% endif %}></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||||||
|
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="section-footer" style="justify-content:space-between">
|
||||||
|
<button class="btn btn-secondary" onclick="addThresholdConfigCard('thresh-cfgs-{{ section.id }}')">+ Add config</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageThresholdsSection('{{ section.id }}')">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ---- YAML editor section ---- #}
|
{# ---- YAML editor section ---- #}
|
||||||
{% elif section.section_mode == 'yaml' %}
|
{% elif section.section_mode == 'yaml' %}
|
||||||
<div style="padding: 12px 20px">
|
<div style="padding: 12px 20px">
|
||||||
@@ -871,9 +998,13 @@
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
document.getElementById('pending-count').textContent = count;
|
document.getElementById('pending-count').textContent = count;
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
|
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
|
||||||
} else {
|
} else {
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
}
|
}
|
||||||
|
const navBtn = document.getElementById('nav-publish-btn');
|
||||||
|
if (navBtn) navBtn.style.display = count > 0 ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageFormSection(sectionId, apiSection) {
|
function stageFormSection(sectionId, apiSection) {
|
||||||
@@ -1004,10 +1135,6 @@
|
|||||||
|
|
||||||
function addHostRow() {
|
function addHostRow() {
|
||||||
const tbody = document.getElementById('hosts-tbody');
|
const tbody = document.getElementById('hosts-tbody');
|
||||||
const sz = n => Math.min(Math.max(n, 1), 4);
|
|
||||||
const usersOpts = _allUsers.map(u => `<option value="${escHtml(u)}">${escHtml(u)}</option>`).join('');
|
|
||||||
const tcOpts = _allThresholdConfigs.map(t => `<option value="${escHtml(t)}">${escHtml(t)}</option>`).join('');
|
|
||||||
const chOpts = _allChannels.map(c => `<option value="${escHtml(c)}">${escHtml(c)}</option>`).join('');
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.setAttribute('data-new-host', 'true');
|
row.setAttribute('data-new-host', 'true');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -1015,10 +1142,10 @@
|
|||||||
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
|
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
|
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
|
||||||
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
|
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
|
||||||
<td><select class="field-input host-managers" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td>
|
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
|
||||||
<td><select class="field-input host-monitors" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td>
|
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
|
||||||
<td><select class="field-input host-tc" multiple size="${sz(_allThresholdConfigs.length)}">${tcOpts}</select></td>
|
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
|
||||||
<td><select class="field-input host-channels" multiple size="${sz(_allChannels.length)}">${chOpts}</select></td>
|
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
|
||||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1177,7 @@
|
|||||||
|
|
||||||
function discardAll() {
|
function discardAll() {
|
||||||
Object.keys(_staged).forEach(k => delete _staged[k]);
|
Object.keys(_staged).forEach(k => delete _staged[k]);
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
updatePendingBanner();
|
updatePendingBanner();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -1092,8 +1220,6 @@
|
|||||||
|
|
||||||
function addUserRow() {
|
function addUserRow() {
|
||||||
const tbody = document.getElementById('users-tbody');
|
const tbody = document.getElementById('users-tbody');
|
||||||
const opts = _allChannels.map(ch => `<option value="${escHtml(ch)}">${escHtml(ch)}</option>`).join('');
|
|
||||||
const chHtml = `<select class="field-input user-ch-select" multiple size="${Math.min(_allChannels.length, 4)}" style="min-width:130px">${opts}</select>`;
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.setAttribute('data-new-user', 'true');
|
row.setAttribute('data-new-user', 'true');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -1101,7 +1227,7 @@
|
|||||||
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
||||||
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
||||||
<td>${chHtml}</td>
|
<td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
||||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -1181,6 +1307,121 @@
|
|||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Multi-picker ----
|
||||||
|
let _mpickPanel = null;
|
||||||
|
let _mpickTarget = null;
|
||||||
|
|
||||||
|
function _initMpickPanel() {
|
||||||
|
if (_mpickPanel) return;
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'mpick-panel';
|
||||||
|
p.style.display = 'none';
|
||||||
|
p.innerHTML = `
|
||||||
|
<div class="mpick-panel-header">
|
||||||
|
<span>Select items</span>
|
||||||
|
<button style="background:none;border:none;cursor:pointer;color:#888;font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-body">
|
||||||
|
<div class="mpick-col" id="mpick-avail-col">
|
||||||
|
<div class="mpick-col-header">Available</div>
|
||||||
|
<div id="mpick-avail"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-col" id="mpick-sel-col">
|
||||||
|
<div class="mpick-col-header">Selected</div>
|
||||||
|
<div id="mpick-sel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-footer">
|
||||||
|
<button class="btn btn-primary" style="font-size:.82em;padding:4px 12px" onclick="closeMpick()">Done</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(p);
|
||||||
|
_mpickPanel = p;
|
||||||
|
document.addEventListener('mousedown', e => {
|
||||||
|
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
|
||||||
|
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMpick(displayEl) {
|
||||||
|
if (displayEl.closest('tr')?.dataset.deleted === 'true') return;
|
||||||
|
_initMpickPanel();
|
||||||
|
_mpickTarget = displayEl.closest('.mpick-wrapper');
|
||||||
|
_rerenderMpick();
|
||||||
|
_mpickPanel.style.display = 'block';
|
||||||
|
const rect = displayEl.getBoundingClientRect();
|
||||||
|
const pw = _mpickPanel.offsetWidth || 360;
|
||||||
|
const ph = _mpickPanel.offsetHeight || 280;
|
||||||
|
let top = rect.bottom + 4;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
|
||||||
|
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
|
||||||
|
_mpickPanel.style.top = top + 'px';
|
||||||
|
_mpickPanel.style.left = left + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rerenderMpick() {
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const allOpts = [...sel.options];
|
||||||
|
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
|
||||||
|
const avail = allOpts.filter(o => !selVals.has(o.value));
|
||||||
|
const chosen = allOpts.filter(o => selVals.has(o.value));
|
||||||
|
document.getElementById('mpick-avail').innerHTML = avail.length
|
||||||
|
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">All selected</div>';
|
||||||
|
document.getElementById('mpick-sel').innerHTML = chosen.length
|
||||||
|
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">−</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">None selected</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mpickToggle(itemEl, toSelected) {
|
||||||
|
const val = itemEl.dataset.val;
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const opt = [...sel.options].find(o => o.value === val);
|
||||||
|
if (opt) opt.selected = toSelected;
|
||||||
|
_updateMpickDisplay(_mpickTarget);
|
||||||
|
_rerenderMpick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMpickDisplay(wrapper) {
|
||||||
|
const sel = wrapper.querySelector('select');
|
||||||
|
const display = wrapper.querySelector('.mpick-display');
|
||||||
|
const selected = [...sel.selectedOptions].map(o => o.value);
|
||||||
|
if (!selected.length) {
|
||||||
|
display.innerHTML = '<span class="mpick-empty">(none)</span>';
|
||||||
|
display.title = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const MAX = 2;
|
||||||
|
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
display.innerHTML = html;
|
||||||
|
display.title = selected.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMpick() {
|
||||||
|
if (_mpickPanel) _mpickPanel.style.display = 'none';
|
||||||
|
_mpickTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMpickHTML(allItems, selectedItems, cls) {
|
||||||
|
const selSet = new Set(selectedItems);
|
||||||
|
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
|
||||||
|
const selected = allItems.filter(v => selSet.has(v));
|
||||||
|
const MAX = 2;
|
||||||
|
let dispHtml;
|
||||||
|
if (!selected.length) {
|
||||||
|
dispHtml = '<span class="mpick-empty">(none)</span>';
|
||||||
|
} else {
|
||||||
|
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
}
|
||||||
|
const title = escHtml(selected.join(', '));
|
||||||
|
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight sidebar link for the section currently in view
|
// Highlight sidebar link for the section currently in view
|
||||||
const sections = document.querySelectorAll('.section');
|
const sections = document.querySelectorAll('.section');
|
||||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||||
@@ -1209,6 +1450,122 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Threshold configurations form ----
|
||||||
|
function stageThresholdsSection(sectionId) {
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
const configs = {};
|
||||||
|
|
||||||
|
function readMetrics(card) {
|
||||||
|
const metrics = {};
|
||||||
|
card.querySelectorAll('tbody tr').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
const metric = row.dataset.metricPath
|
||||||
|
|| (row.querySelector('.new-metric-path')?.value || '').trim();
|
||||||
|
if (!metric) return;
|
||||||
|
const op = row.querySelector('.thresh-op')?.value || '>';
|
||||||
|
const warn = row.querySelector('.thresh-warn')?.value;
|
||||||
|
const crit = row.querySelector('.thresh-crit')?.value;
|
||||||
|
const hyst = row.querySelector('.thresh-hyst')?.value;
|
||||||
|
const count = row.querySelector('.thresh-count')?.value;
|
||||||
|
const display = row.querySelector('.thresh-display')?.value || '';
|
||||||
|
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
|
||||||
|
const entry = { operator: op, enabled: enabled };
|
||||||
|
if (warn !== '' && warn !== undefined) entry.warning = parseFloat(warn);
|
||||||
|
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
|
||||||
|
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
|
||||||
|
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
|
||||||
|
if (display) entry.display = display;
|
||||||
|
metrics[metric] = entry;
|
||||||
|
});
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
|
||||||
|
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
|
||||||
|
const configName = card.dataset.configName
|
||||||
|
|| (card.querySelector('.new-config-name')?.value || '').trim();
|
||||||
|
if (!configName) return;
|
||||||
|
configs[configName] = readMetrics(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
_staged['thresholds'] = configs;
|
||||||
|
|
||||||
|
const defInput = section.querySelector('.thresh-default-config');
|
||||||
|
if (defInput) {
|
||||||
|
if (!_staged['server']) _staged['server'] = {};
|
||||||
|
_staged['server']['default_threshold_config'] = defInput.value || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThreshOpChange(select) {
|
||||||
|
const row = select.closest('tr');
|
||||||
|
const isNagios = select.value === 'nagios';
|
||||||
|
const w = row.querySelector('.thresh-warn');
|
||||||
|
const c = row.querySelector('.thresh-crit');
|
||||||
|
if (w) w.disabled = isNagios;
|
||||||
|
if (c) c.disabled = isNagios;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _threshOpSelect(selected) {
|
||||||
|
const ops = ['>', '>=', '<', '<=', '==', '!=', 'nagios'];
|
||||||
|
return '<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">' +
|
||||||
|
ops.map(op => `<option value="${escHtml(op)}"${op === selected ? ' selected' : ''}>${escHtml(op)}</option>`).join('') +
|
||||||
|
'</select>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdMetricRow(tbody) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="text" class="field-input new-metric-path" placeholder="plugin.metric" style="min-width:160px;font-family:monospace;font-size:.85em" required></td>
|
||||||
|
<td>${_threshOpSelect('>')}</td>
|
||||||
|
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
|
||||||
|
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdConfigCard(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'thresh-cfg-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="thresh-cfg-header">
|
||||||
|
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
|
||||||
|
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="crud-table thresh-metric-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Metric path</th><th>Op</th>
|
||||||
|
<th>Warning</th><th>Critical</th>
|
||||||
|
<th>Hysteresis</th><th>Count</th>
|
||||||
|
<th style="max-width:160px">Display</th>
|
||||||
|
<th>En</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||||||
|
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteThresholdConfigCard(btn) {
|
||||||
|
const card = btn.closest('.thresh-cfg-card');
|
||||||
|
const name = card.dataset.configName || 'this config';
|
||||||
|
if (!confirm(`Delete config "${name}"?`)) return;
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
|
||||||
function closeSidebar() {
|
function closeSidebar() {
|
||||||
var sidebarNav = document.getElementById('sidebar-nav');
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
|||||||
+1
-1
@@ -377,7 +377,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
default_owner = config_mod.get_default_owner(cfg)
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
||||||
host.owner = inferred_owner
|
host.owner = inferred_owner
|
||||||
logger.info(f"owner for {uname} is '{host.owner}")
|
logger.info(f"owner for {uname} is {host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
print(f"Stored plugin data for {uname}: {plugin_name}")
|
print(f"Stored plugin data for {uname}: {plugin_name}")
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.2"
|
version = "5.3.4"
|
||||||
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.2"
|
__version__ = "5.3.4"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
|
|||||||
import hbdclass
|
import hbdclass
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"config": {"watchhosts": [], "dyndnshosts": []},
|
"config": {"watchhosts": []},
|
||||||
"hbdclass": hbdclass,
|
"hbdclass": hbdclass,
|
||||||
"log": dummy_noop,
|
"log": dummy_noop,
|
||||||
"email": dummy_noop,
|
"email": dummy_noop,
|
||||||
|
|||||||
Reference in New Issue
Block a user