Compare commits

...

21 Commits

Author SHA1 Message Date
Andreas Wrede 4e5bafd26c version 5.3.4
Release / release (push) Successful in 5s
2026-05-12 15:06:24 -04:00
Andreas Wrede 817ae064af fix: run full reload after HTTP config publish, not just config.reload()
HTTP config-mutating endpoints (publish, rollback, channel CRUD, user
self-update) were calling config.reload() directly, which only refreshed
the in-memory config dict. This skipped re-applying host.dyn/host.watched
flags to live Host objects, so enabling dyndns via the UI had no effect
until a SIGHUP was sent.

Wire a reload_callback through http.start() that calls the same
reload_configuration() function used by the SIGHUP handler, ensuring
host attributes, notify module, users, and threshold checker are all
updated on every config publish.

Also fix unmatched quote in udp.py f-string log message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:05:52 -04:00
Andreas Wrede a00282913b version 5.3.3
Release / release (push) Successful in 5s
2026-05-12 14:34:58 -04:00
Andreas Wrede d699a29fa9 refactor: remove dyndnshosts/drophosts legacy config keys, fix DNS event logging
- Remove dyndnshosts legacy list; dyndns is now set per-host in the hosts section
- Remove drophosts config key and load-time deletion loop
- Simplify get_dyndnshosts() to only read per-host dyndns attributes
- Fix dns_update_worker to call eventlog with correct (host, level, msg) signature
- Log INFO/ERROR events per domain on each DNS update instead of one batched message
- Add logger to dns.py (was missing, causing NameError on update failure)
- Update README and tests to reflect removed config keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:34:11 -04:00
Andreas Wrede 4ce7eacfdd fix: remove container max-width and stop stretching inputs on settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:42:54 -04:00
Andreas Wrede 1cefc2676e feat: replace YAML editor with form UI for threshold configurations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:57:03 -04:00
Andreas Wrede 668a135e53 feat: replace multi-select fields with dual-panel picker on settings page
Replaces the 5 native <select multiple> fields (Managers, Monitors,
Threshold config, Channels in Hosts; Channels in Users) with a compact
picker widget: a truncated pill display with tooltip, and a click-to-open
panel split into Available / Selected columns for moving items between sides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:10:18 -04:00
Andreas Wrede 59e256a042 feat: add nav bar button to publish pending config changes
Shows an orange "Publish Config" button to the left of the alert-pie
for admin users when there are staged config changes. Uses localStorage
to persist staged changes across page navigations so the button appears
on any page, not just settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:32:32 -04:00
Andreas Wrede 708508157f feat: add host, level, and message filters to Log of Events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:29:26 -04:00
Andreas Wrede f67fa9baff version 5.3.2
Release / release (push) Successful in 5s
2026-05-12 08:16:04 -04:00
Andreas Wrede 588eb2a792 feat: retry DNS resolution indefinitely and add -4/-6 flags in hbc and hbc_mini.c
Mirror the same changes from hbc_mini.py: retry host resolution with
exponential backoff (5s→60s) instead of exiting on DNS failure, and add
mutually exclusive -4 / -6 flags to restrict connections to IPv4 or IPv6.

In hbc (main.py) the retry sleep is interruptible via the shutdown_event.
In hbc_mini.c signal handlers are moved before the resolution loop so
SIGINT/SIGTERM can break the retry during startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:15:53 -04:00
Andreas Wrede b907343e36 feat: retry DNS resolution indefinitely and add -4/-6 flags in hbc_mini
On startup, retry host resolution with exponential backoff (5s→60s) instead
of exiting when DNS fails. Add mutually exclusive -4 / -6 CLI flags to
restrict connections to IPv4 or IPv6 only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:07:54 -04:00
Andreas Wrede e50a3996ae fix: support list-valued threshold_config in hosts table
threshold_config in .hb.yaml can be a list (e.g. [local, zrepl]).
The hosts table was treating it as a single string, so the pre-selected
value never matched. Normalize to a list in settings.py, switch the
select to multiple, and fix the JS to collect all selected options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:22:07 -04:00
Andreas Wrede e1056a0365 fix: derive hosts threshold config list from config file keys
Previously all_threshold_configs was built from the threshold_checker
object, which may not be populated at render time, leaving the select
empty. Read directly from config["threshold_configs"] instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:09:27 -04:00
Andreas Wrede 1dbe0f8e64 feat: replace YAML hosts editor with form-based CRUD table
Settings > Hosts now renders a table with per-column controls
(watch, dyndns, owner, managers/monitors multi-select, threshold
config, notification channels) instead of a raw YAML textarea.
Changes stage via the existing Publish flow like other form sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:57:28 -04:00
Andreas Wrede 12e8812070 docs: update notification channel and API docs for form-based management
- NOTIFICATIONS.md: document owner/private fields, channel visibility
  rules, and user-created channels; add troubleshooting note for
  private channel visibility
- HTTP_API.md: add notification channel API endpoints table and full
  endpoint reference (GET types, GET/POST/PUT/DELETE channels)
- USERS.md: add missing PUT /api/0/users/me endpoint documentation
  with all three update modes (identity, channels, password)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:45:30 -04:00
Andreas Wrede 9b5d8ac9b1 fix: replace channel checkboxes in Users table with multi-select
The per-user notification channel selector in the admin settings Users
section was a column of checkboxes; replaced with a <select multiple>
for consistency with the profile chip picker and to reduce table width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:38:56 -04:00
Andreas Wrede 500d256d76 feat: replace YAML notification channel editor with form-based UI
Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.

- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
  section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
  GET /api/0/notification_channel_types; visibility helper filters private
  channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
  with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
  for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
  test_notification_channels_api (16 new tests, 46 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:34:26 -04:00
Andreas Wrede a7a45bf8c3 fix: support plugin-level enabled: false in threshold config
Setting enabled: false at the plugin level (e.g. memory_monitor: {enabled: false})
was silently ignored because the non-dict value was skipped by the metric parser,
leaving THRESHOLD_DEFAULTS entries active.

- _parse_plugin_thresholds: detect plugin-level enabled/enable flag and delete
  all matching entries from target_dict (covers legacy and default config paths)
- _parse_multi_config named configs: inject disabled stubs from effective_defaults
  into raw_overrides so the merge step overwrites inherited defaults
- Accept 'enable' as a tolerated alias for 'enabled' in both code paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:40:29 -04:00
Andreas Wrede 3e9b052f71 fix: always populate glance-strip for all hosts on page load
fetchHostGlance was only called for the initially expanded host, leaving
all other hosts showing "—" until manually expanded. Now fetches glance
for every host-card on DOMContentLoaded and refreshes all (not just
expanded) on the 30s auto-refresh interval.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:13:10 -04:00
Andreas Wrede 7444262985 fix: fetch host info on initial page load
DOMContentLoaded was calling fetchHostGlance but not fetchHostInfo,
leaving the info-meta section stuck on "Loading…". Both the URL-hash
and default first-host paths now call fetchHostInfo and populate
infoCache on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:08:37 -04:00
28 changed files with 2256 additions and 215 deletions
+6 -9
View File
@@ -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) ✅
- 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) ✅
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
- **User management & access control** ✅
@@ -398,6 +398,7 @@ hosts:
owner: alice
managers: [bob]
monitors: [carol]
dyndns: true # update DNS record when IP changes
```
```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
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
- `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
- `ws_port`: port for plain WebSocket connections (default: 50005)
- `wss_port`: port for secure WebSocket (WSS) connections (default: none).
@@ -666,6 +667,9 @@ dyndomains:
- example.com
nsupdate_bin: /usr/bin/nsupdate
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.
@@ -769,10 +773,3 @@ Contributions welcome! Please:
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? ✨
+106
View File
@@ -53,6 +53,17 @@ See [User Management](USERS.md) for full authentication documentation.
|--------|------|-------------|------|
| `GET` | `/api/0/users` | List all users | Admin |
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
| `PUT` | `/api/0/users/me` | Update own profile | Authenticated |
### Notification Channels
| Method | Path | Description | Role |
|--------|------|-------------|------|
| `GET` | `/api/0/notification_channel_types` | Channel type schemas | Authenticated |
| `GET` | `/api/0/notification_channels` | List visible channels | Authenticated |
| `POST` | `/api/0/notification_channels` | Create a channel | Authenticated |
| `PUT` | `/api/0/notification_channels/{name}` | Update a channel | Owner or Admin |
| `DELETE` | `/api/0/notification_channels/{name}` | Delete a channel | Owner or Admin |
### Host Management
@@ -203,6 +214,101 @@ Changes take effect immediately but are not written back to the config file. Upd
---
---
### Notification Channel Endpoints
Channels are visible to all users by default. Channels marked `private: true` are only visible to their owner. Admins see all channels.
#### GET /api/0/notification_channel_types
Return the schema for every supported notifier type. Used by the web UI to dynamically render the channel creation form.
**Response:**
```json
{
"pushover": {
"label": "Pushover",
"fields": [
{"key": "token", "label": "App token", "type": "secret", "required": true},
{"key": "user", "label": "User key", "type": "secret", "required": true},
{"key": "sound", "label": "Sound", "type": "text", "required": false}
]
},
"email": { "label": "E-mail", "fields": [ ... ] },
...
}
```
---
#### GET /api/0/notification_channels
List channels visible to the current user (public channels + own private channels). Admins receive all channels.
**Response:**
```json
[
{
"name": "pushover_ops",
"type": "pushover",
"type_label": "Pushover",
"owner": null,
"private": false,
"min_level": "WARNING",
"fields": [
{"key": "token", "label": "App token", "value": "•••", "sensitive": true},
{"key": "user", "label": "User key", "value": "•••", "sensitive": true}
]
}
]
```
Sensitive fields (`type: "secret"`) are always returned as `"•••"`.
---
#### POST /api/0/notification_channels
Create a new channel. The creating user becomes the channel's `owner`.
**Request body:**
```json
{
"name": "my_pushover",
"type": "pushover",
"token": "app-token",
"user": "user-key",
"min_level": "WARNING",
"private": true
}
```
**Response:** `{"ok": true, "name": "my_pushover"}`
**Status codes:** `200 OK`, `400` (missing required field or unknown type), `409` (name already exists)
---
#### PUT /api/0/notification_channels/{name}
Update an existing channel. Only the channel owner or an admin may update it.
Secret fields sent as `"•••"` are preserved from the existing config (same pattern as OAuth secrets in the admin config editor).
**Request body:** same shape as POST, `name` ignored (taken from URL).
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
---
#### DELETE /api/0/notification_channels/{name}
Delete a channel. Only the channel owner or an admin may delete it.
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
---
### Alert Endpoints
#### GET /api/0/hosts/{hostname}/alerts
+37 -7
View File
@@ -30,9 +30,17 @@ Set `base_url` so notification links point to your hbd instance:
base_url: https://hbd.example.com
```
### Global channel definitions
### Channel definitions
Define channels once; reference them by name from user configs:
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
| Field | Default | Description |
|---|---|---|
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
| `private` | `false` | When `true`, only the owner can see and select this channel. |
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
**Admin-created channels** (set in the config file or via the admin settings UI) are public by default — all users can select them:
```yaml
notification_channels:
@@ -41,7 +49,7 @@ notification_channels:
type: pushover
token: your-app-token
user: your-user-key
min_level: WARNING # optional, default: WARNING
min_level: WARNING
email_ops:
type: email
@@ -58,14 +66,14 @@ notification_channels:
homeserver: https://matrix.example.org
access_token: syt_xxx
room_id: "!abc:matrix.example.org"
min_level: CRITICAL # only send critical alerts to this room
min_level: CRITICAL
sms_oncall:
type: sms_voipms
api_user: me@example.com
api_password: secret
did: "5551234567" # your voip.ms DID number
dst: "5559876543" # destination number
did: "5551234567"
dst: "5559876543"
min_level: CRITICAL
signal_ops:
@@ -82,9 +90,30 @@ notification_channels:
username: heartbeat-bot
```
**User-created channels** are written by authenticated users through the API or their profile page. They carry an `owner` field and optionally `private: true`:
```yaml
notification_channels:
alice_personal:
type: pushover
token: personal-token
user: personal-key
owner: alice # created by alice
private: true # only alice can see this channel
```
### Channel visibility
| Channel | Who can see / select it |
|---|---|
| No `private` field (or `private: false`) | All users |
| `private: true` | Only the `owner` |
| Any channel | Admins always see everything |
### Users with notification channels
Each user lists which global channels they receive notifications on:
Each user lists which channels they receive notifications on. Users can manage their own selection from the profile page:
```yaml
users:
@@ -270,6 +299,7 @@ Called once at startup from `main.py`. Pass the running asyncio event loop so Ma
- Check that the host has an `owner` or `managers` set
- Check that users have `notification_channels` listed
- Check that the channel names in user config match keys under `notification_channels:`
- If a user can't select a channel, check whether it is `private: true` and owned by someone else
**min_level filtering too aggressive:**
- Default is `WARNING` — both WARNING and CRITICAL are sent
+27 -1
View File
@@ -36,7 +36,7 @@ users:
bob:
full_name: Bob Smith
password: pbkdf2:sha256:...
notification_channels: [pushover_standard]
notification_channels: [pushover_standard] # channels bob has selected
carol:
full_name: Carol Jones
@@ -188,6 +188,32 @@ Return the currently authenticated user's profile.
---
#### PUT /api/0/users/me
Update the current user's profile. All fields are optional — send only what you want to change.
**Update display name and avatar:**
```json
{ "full_name": "Carol Jones", "avatar": "/avatars/carol.png" }
```
**Change notification channel selection:**
```json
{ "notification_channels": ["pushover_ops", "email_ops"] }
```
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
**Change password:**
```json
{ "password": { "current": "oldpass", "new": "newpass" } }
```
Requires the correct current password. New password is hashed before storage.
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `400` (missing/invalid field), `401` (unauthenticated), `403` (wrong current password)
---
### Host Access
#### GET /api/0/hosts/{hostname}/access
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
"""
__all__ = ["__version__"]
__version__ = "5.3.1"
__version__ = "5.3.4"
+20 -5
View File
@@ -518,29 +518,41 @@ async def async_main(args, config):
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
else 0)
# Create connections
connections = []
conn_id = 1
_retry_delay = 5
while running and not connections:
for host in hb_hosts:
try:
addrs = socket.getaddrinfo(host, hb_port, 0, 0, socket.SOL_UDP)
addrs = socket.getaddrinfo(host, hb_port, af_filter, 0, socket.SOL_UDP)
except socket.gaierror as e:
logger.error(f"Cannot resolve {host}: {e}")
logger.warning(f"Cannot resolve {host}: {e} — retrying in {_retry_delay}s")
continue
for addr_info in addrs:
af = addr_info[0]
addr = addr_info[4][0]
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
if not await conn.open():
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
connections.append(conn)
conn_id += 1
if not connections:
try:
if shutdown_event:
await asyncio.wait_for(shutdown_event.wait(), timeout=_retry_delay)
else:
await asyncio.sleep(_retry_delay)
except asyncio.TimeoutError:
pass
_retry_delay = min(_retry_delay * 2, 60)
if not connections:
logger.error("No connections established (DNS resolution failed for all hosts)")
return 1
logger.info(f"Created {len(connections)} connections")
@@ -726,6 +738,9 @@ def build_parser():
default=0,
help="Increase debug level"
)
af_group = parser.add_mutually_exclusive_group()
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
parser.add_argument(
"hosts",
nargs="+",
+9 -28
View File
@@ -39,8 +39,6 @@ SERVER_DEFAULTS = {
# Host management
"hosts": {}, # Unified host definitions
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
"drophosts": [], # Hosts to ignore
"dyndomains": ["wrede.org"],
# DNS updates
@@ -249,7 +247,7 @@ def get_watchhosts(config):
"""Extract watched hostnames from config (hosts with watch: true).
Returns:
List of hostnames to watch
# List of hostnames to watch
"""
watchhosts = []
hosts_config = config.get("hosts", {})
@@ -261,31 +259,14 @@ def get_watchhosts(config):
def get_dyndnshosts(config):
"""Extract dyndnshosts from config, supporting both new and legacy formats.
Args:
config: Configuration dictionary
Returns:
List of hostnames with dynamic DNS
"""
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
"""Return hostnames that have a dyndns setting in the hosts section."""
hosts_config = config.get("hosts", {})
if not isinstance(hosts_config, dict):
return []
return [
name for name, attrs in hosts_config.items()
if isinstance(attrs, dict) and attrs.get("dyndns")
]
def get_host_config(config, hostname):
+17 -1
View File
@@ -21,10 +21,11 @@ _SERVER_KEYS = [
"interval", "grace", "base_url", "threshold_renotify_interval",
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
"journal_max_size", "journal_max_backups", "default_owner",
"default_threshold_config",
]
# 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):
@@ -89,10 +90,25 @@ def apply_structured_section(data, section: str, values: dict) -> None:
data[key] = values[key]
elif section == "users":
data["users"] = values
elif section == "hosts":
data["hosts"] = values
else:
raise ValueError(f"Unknown structured section: {section!r}")
def apply_channel(data, name: str, channel_cfg: dict) -> None:
"""Insert or replace a single notification channel entry, preserving others."""
if not data.get("notification_channels"):
data["notification_channels"] = {}
data["notification_channels"][name] = channel_cfg
def delete_channel(data, name: str) -> None:
"""Remove a notification channel by name. No-op if not found."""
nc = data.get("notification_channels") or {}
nc.pop(name, None)
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
"""Replace the named logical section by parsing yaml_text."""
parsed = _make_yaml().load(yaml_text)
+18 -15
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
from subprocess import Popen, PIPE, STDOUT
from typing import Optional
import asyncio
import logging
logger = logging.getLogger(__name__)
def create_nsupdate_payload(
@@ -123,7 +126,6 @@ async def dns_update_worker(
pass
continue
m = f"changed address to {addr}"
for dyndomain in cfg.get("dyndomains", []):
err = await loop.run_in_executor(
None,
@@ -135,28 +137,29 @@ async def dns_update_worker(
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
)
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)
if log:
try:
await loop.run_in_executor(None, log, name, "ERROR", m)
except Exception:
pass
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:
dnsq.task_done()
except Exception:
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(
hbdclass,
+349 -6
View File
@@ -25,6 +25,74 @@ logger = logging.getLogger(__name__)
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:
tmpl = jinja2.Template(html_str)
return tmpl.render(**context)
@@ -196,6 +264,7 @@ async def start(
get_now=None,
VER="",
threshold_checker=None,
reload_callback=None,
):
"""Start an aiohttp web server and block until cancelled.
@@ -956,7 +1025,23 @@ async def start(
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
# Build visible channels list for chip picker and My Channels management.
visible_channels = _visible_channels_for_user(current_user) if current_user else {}
all_channels = sorted(
[
{
"name": name,
"type": cfg.get("type", ""),
"owner": cfg.get("owner"),
"private": bool(cfg.get("private", False)),
}
for name, cfg in visible_channels.items()
if isinstance(cfg, dict)
],
key=lambda c: c["name"],
)
# Keep all_channel_names for backwards-compat with any template references.
all_channel_names = [c["name"] for c in all_channels]
tmpl = env.get_template("profile.html")
body = tmpl.render(
@@ -967,6 +1052,7 @@ async def start(
managed_hosts=managed,
monitored_hosts=monitored,
notification_channels=notif_channels,
all_channels=all_channels,
all_channel_names=all_channel_names,
active_page="profile",
)
@@ -1032,6 +1118,8 @@ async def start(
title="Settings - Heartbeat",
sections=settings_data["sections"],
all_channel_names=settings_data["all_channel_names"],
all_usernames=settings_data["all_usernames"],
all_threshold_configs=settings_data["all_threshold_configs"],
current_user=current_user.to_dict() if current_user else None,
active_page="settings",
)
@@ -1209,16 +1297,32 @@ async def start(
attrs.pop("client_secret", None)
data["oauth"] = new_oauth
for section in ("notification_channels", "thresholds", "hosts", "dns"):
for section in ("notification_channels", "dns"):
if section in payload:
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:
h = payload["hosts"]
if isinstance(h, dict):
configio_mod.apply_structured_section(data, "hosts", h)
else:
configio_mod.apply_yaml_section(data, "hosts", h)
configio_mod.write_config(_config_path, data)
except Exception as exc:
logger.error("Config write failed: %s", exc)
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()
users_mod.load_users(config)
@@ -1249,12 +1353,240 @@ async def start(
logger.error("Rollback failed: %s", exc)
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()
users_mod.load_users(config)
return web.json_response({"ok": True})
# -------------------------------------------------------------------------
# Notification channel helpers
# -------------------------------------------------------------------------
def _visible_channels_for_user(user):
"""Return {name: cfg} of channels visible to user (public + own private)."""
all_channels = config.get("notification_channels") or {}
if user is None:
return {}
if user.admin:
return dict(all_channels)
visible = {}
for name, cfg in all_channels.items():
if not isinstance(cfg, dict):
continue
if not cfg.get("private") or cfg.get("owner") == user.username:
visible[name] = cfg
return visible
def _build_channel_response(ch_name, ch_cfg):
"""Serialize a channel config dict for the API response."""
ch_type = ch_cfg.get("type", "")
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
fields = []
for sf in schema_fields:
k = sf["key"]
v = ch_cfg.get(k, "")
sensitive = sf["type"] == "secret"
fields.append({
"key": k,
"label": sf["label"],
"value": "•••" if (sensitive and v) else (
", ".join(v) if isinstance(v, list) else str(v or "")
),
"sensitive": sensitive,
})
return {
"name": ch_name,
"type": ch_type,
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
}
# -------------------------------------------------------------------------
# Notification channel API (any authenticated user)
# -------------------------------------------------------------------------
async def api_notification_channel_types(request):
"""GET /api/0/notification_channel_types — channel type schemas."""
user, err = _require_auth(request)
if err:
return err
return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS)
async def api_notification_channels_get(request):
"""GET /api/0/notification_channels — list channels visible to current user."""
user, err = _require_auth(request)
if err:
return err
visible = _visible_channels_for_user(user)
result = [
_build_channel_response(name, cfg)
for name, cfg in visible.items()
if isinstance(cfg, dict)
]
return web.json_response(result)
async def api_notification_channels_post(request):
"""POST /api/0/notification_channels — create a new channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
name = (body.get("name") or "").strip()
if not name:
return web.json_response({"error": "Channel name is required"}, status=400)
ch_type = (body.get("type") or "").strip()
if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS:
return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400)
if name in (config.get("notification_channels") or {}):
return web.json_response({"error": f"Channel {name!r} already exists"}, status=409)
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type]
channel_cfg = {"type": ch_type}
for sf in schema["fields"]:
k = sf["key"]
v = body.get(k, "")
if v:
channel_cfg[k] = v
elif sf["required"]:
return web.json_response({"error": f"Field {k!r} is required"}, status=400)
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
channel_cfg["owner"] = user.username
if body.get("private"):
channel_cfg["private"] = True
try:
disk_data = configio_mod.read_roundtrip(_config_path)
configio_mod.apply_channel(disk_data, name, channel_cfg)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel create failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if reload_callback:
await reload_callback()
elif hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True, "name": name})
async def api_notification_channel_put(request):
"""PUT /api/0/notification_channels/{name} — update a channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
ch_name = request.match_info["name"]
existing_channels = config.get("notification_channels") or {}
if ch_name not in existing_channels:
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
existing_cfg = existing_channels[ch_name]
if not isinstance(existing_cfg, dict):
return web.json_response({"error": "Invalid channel config"}, status=500)
owner = existing_cfg.get("owner")
if not user.admin and owner != user.username:
return web.json_response({"error": "Forbidden"}, status=403)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
ch_type = existing_cfg.get("type", "")
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
secret_keys = {sf["key"] for sf in schema_fields if sf["type"] == "secret"}
try:
disk_data = configio_mod.read_roundtrip(_config_path)
existing_on_disk = (disk_data.get("notification_channels") or {}).get(ch_name, {})
channel_cfg = {"type": ch_type}
for sf in schema_fields:
k = sf["key"]
v = body.get(k, "")
if k in secret_keys and (not v or v == "•••"):
existing_val = existing_on_disk.get(k, "")
if existing_val:
channel_cfg[k] = existing_val
elif v:
channel_cfg[k] = v
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
if owner is not None:
channel_cfg["owner"] = owner
if "private" in body:
channel_cfg["private"] = bool(body["private"])
elif existing_on_disk.get("private"):
channel_cfg["private"] = True
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel update failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if reload_callback:
await reload_callback()
elif hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True})
async def api_notification_channel_delete(request):
"""DELETE /api/0/notification_channels/{name} — delete a channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
ch_name = request.match_info["name"]
existing_channels = config.get("notification_channels") or {}
if ch_name not in existing_channels:
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
existing_cfg = existing_channels[ch_name]
owner = existing_cfg.get("owner") if isinstance(existing_cfg, dict) else None
if not user.admin and owner != user.username:
return web.json_response({"error": "Forbidden"}, status=403)
try:
disk_data = configio_mod.read_roundtrip(_config_path)
configio_mod.delete_channel(disk_data, ch_name)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel delete failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if reload_callback:
await reload_callback()
elif hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True})
async def api_user_self_put(request):
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
user, err = _require_auth(request)
@@ -1297,7 +1629,10 @@ async def start(
if "avatar" in body:
user_entry["avatar"] = str(body["avatar"])
if "notification_channels" in body:
user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]]
visible = _visible_channels_for_user(user)
user_entry["notification_channels"] = [
str(ch) for ch in body["notification_channels"] if ch in visible
]
if password_change:
user_entry["password"] = users_mod.hash_password(password_change["new"])
@@ -1307,7 +1642,9 @@ async def start(
logger.error("User self-update failed: %s", exc)
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()
users_mod.load_users(config)
@@ -1337,6 +1674,12 @@ async def start(
web.get("/api/0/config/backups", api_config_backups_get),
web.post("/api/0/config", api_config_post),
web.post("/api/0/config/rollback", api_config_rollback),
# Notification channel API (any authenticated user)
web.get("/api/0/notification_channel_types", api_notification_channel_types),
web.get("/api/0/notification_channels", api_notification_channels_get),
web.post("/api/0/notification_channels", api_notification_channels_post),
web.put("/api/0/notification_channels/{name}", api_notification_channel_put),
web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete),
# Hosts
web.get("/api/0/hosts", api_hosts),
web.get("/api/0/alert_summary", api_alert_summary),
+5 -9
View File
@@ -78,9 +78,7 @@ async def reload_configuration(config_obj, config_path, components):
True if reload succeeded, False otherwise
"""
try:
logger.info("=" * 60)
logger.info("Starting configuration reload...")
logger.info("=" * 60)
# Reload config file
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:
# - notification_channels
# - threshold_configs
# - hosts (watchhosts, dyndnshosts, notification_channels)
# - hosts (watchhosts, dyndns, notification_channels)
# - grace period (used on next heartbeat)
# - debug/verbose flags (used on next message)
logger.info("=" * 60)
logger.info("Configuration reload completed successfully")
logger.info("=" * 60)
return True
except Exception as e:
@@ -246,6 +242,9 @@ async def _run_async(config, config_path=None):
# upgrade or config change between runs).
threshold_checker.purge_stale_alerts(hbdclass)
async def _http_reload_callback():
await reload_configuration(config, config_path, components)
# HTTP server (asyncio-based via aiohttp)
try:
http_task = asyncio.create_task(
@@ -259,6 +258,7 @@ async def _run_async(config, config_path=None):
verbose=config.get("verbose", False),
get_now=lambda: time.time(),
VER="",
reload_callback=_http_reload_callback,
)
)
logger.info(
@@ -422,7 +422,6 @@ def load_pickled_hosts(config, hbdclass):
pickfile = config.get("pickfile", "hbd.pickle")
dyndnshosts = config_mod.get_dyndnshosts(config)
watchhosts = config_mod.get_watchhosts(config)
drophosts = config.get("drophosts", [])
if 1 and os.path.exists(pickfile):
if config.get("verbose", False):
logger.info("opening pickls %s", pickfile)
@@ -448,9 +447,6 @@ def load_pickled_hosts(config, hbdclass):
hbdclass.Host.hosts[h].apply_access(
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):
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
else:
+3
View File
@@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
"""Send *notif* to a single named channel, honouring min_level."""
# Strip ownership metadata — notifier drivers only need delivery credentials.
channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")}
level = notif.level.upper()
if level != "RECOVER":
min_level = channel_cfg.get("min_level", "WARNING").upper()
+79 -12
View File
@@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({
"smtp_password", "smtp_user", "api_password", "access_token",
})
_CHANNEL_TYPE_LABELS = {
"pushover": "Pushover",
"email": "E-mail",
"signal": "Signal",
"mattermost": "Mattermost",
CHANNEL_TYPE_SCHEMAS = {
"pushover": {
"label": "Pushover",
"fields": [
{"key": "token", "label": "App token", "type": "secret", "required": True},
{"key": "user", "label": "User key", "type": "secret", "required": True},
{"key": "sound", "label": "Sound", "type": "text", "required": False},
],
},
"email": {
"label": "E-mail",
"fields": [
{"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True},
{"key": "sender", "label": "From address", "type": "text", "required": True},
{"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True},
{"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False},
{"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False},
{"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False},
],
},
"signal": {
"label": "Signal",
"fields": [
{"key": "user", "label": "Sender number", "type": "text", "required": True},
{"key": "recipient", "label": "Recipient number", "type": "text", "required": True},
{"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False},
],
},
"matrix": {
"label": "Matrix",
"fields": [
{"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True},
{"key": "access_token", "label": "Access token", "type": "secret", "required": True},
{"key": "room_id", "label": "Room ID", "type": "text", "required": True},
],
},
"sms_voipms": {
"label": "SMS (voip.ms)",
"fields": [
{"key": "api_user", "label": "API username", "type": "text", "required": True},
{"key": "api_password", "label": "API password", "type": "secret", "required": True},
{"key": "did", "label": "DID (from)", "type": "text", "required": True},
{"key": "dst", "label": "Destination", "type": "text", "required": True},
],
},
"mattermost": {
"label": "Mattermost",
"fields": [
{"key": "host", "label": "Host", "type": "text", "required": True},
{"key": "token", "label": "Webhook token", "type": "secret", "required": True},
{"key": "channel", "label": "Channel", "type": "text", "required": True},
{"key": "username", "label": "Bot username", "type": "text", "required": False},
{"key": "icon", "label": "Icon URL", "type": "text", "required": False},
],
},
}
_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()}
def _mask(value):
"""Return a masked placeholder for sensitive values."""
@@ -143,6 +195,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
}
# ---- Notification channels (complex, built separately) ----------------
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
notif_channels = []
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
if not isinstance(ch_cfg, dict):
@@ -150,7 +203,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
ch_type = ch_cfg.get("type", "")
fields = []
for k, v in ch_cfg.items():
if k == "type":
if k in _METADATA_KEYS:
continue
sensitive = k in _SECRET_KEYS
fields.append({
@@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"name": ch_name,
"type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
})
@@ -191,6 +247,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"hysteresis": tc.hysteresis,
"count": tc.count,
"enabled": tc.enabled,
"display": tc.display or "",
}
threshold_config_list = []
@@ -228,7 +285,10 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"owner": hcfg.get("owner", ""),
"managers": hcfg.get("managers", []),
"monitors": hcfg.get("monitors", []),
"threshold_config": hcfg.get("threshold_config", ""),
"threshold_configs": (
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
else ([v] if v else [])
),
"notification_channels": hcfg.get("notification_channels", []),
})
@@ -368,7 +428,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "channels",
"title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.",
"section_mode": "yaml",
"section_mode": "channels",
"api_section": "notification_channels",
"channels": notif_channels,
"fields": [
@@ -380,7 +440,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "hosts",
"title": "Hosts",
"description": "Host definitions loaded from the config file.",
"section_mode": "yaml",
"section_mode": "hosts",
"api_section": "hosts",
"hosts": hosts_list,
"fields": [],
@@ -389,12 +449,12 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "thresholds",
"title": "Threshold Configurations",
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
"section_mode": "yaml",
"section_mode": "thresholds",
"api_section": "thresholds",
"threshold_configs": threshold_config_list,
"fields": [
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),
],
},
{
@@ -419,4 +479,11 @@ def get_settings_data(config: dict, threshold_checker=None) -> dict:
"""Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker)
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
return {"sections": sections, "all_channel_names": all_channel_names}
all_usernames = sorted((config.get("users") or {}).keys())
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
return {
"sections": sections,
"all_channel_names": all_channel_names,
"all_usernames": all_usernames,
"all_threshold_configs": all_threshold_configs,
}
+17
View File
@@ -125,6 +125,23 @@
.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 */
.nav-pie {
flex-shrink: 0;
+73 -2
View File
@@ -201,6 +201,43 @@
.log-recover .log-level { color: #2a7a2a; }
.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 */
.connection-modal {
display: none;
@@ -445,6 +482,22 @@
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() {
if ("WebSocket" in window) {
//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())
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
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, '&quot;') + '">';
html += '<span class="log-ts">' + ts_str + '</span>';
html += '<span class="log-level">' + (msg.level || "") + '</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 += '</div>';
msgs.insertAdjacentHTML("afterbegin", html);
applyLogFilters();
}
cnt++;
};
@@ -575,7 +630,20 @@
</div>
<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>
</div>
@@ -591,6 +659,9 @@
<script>
setup();
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
</script>
</body>
</html>
+38
View File
@@ -11,6 +11,9 @@
{% endif %}
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
</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">&#9888; Publish Config</button>
{% endif %}
<div class="nav-pie" title="Host alert status">
<canvas id="alert-pie" width="44" height="44"></canvas>
</div>
@@ -92,5 +95,40 @@
document.addEventListener('DOMContentLoaded', function() {
updateAlertPie();
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>
+29 -11
View File
@@ -1305,9 +1305,12 @@
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
setInterval(() => {
document.querySelectorAll('.host-card').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
const hostname = card.dataset.hostname;
fetchHostGlance(hostname);
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
const pname = acc.dataset.plugin;
@@ -1327,24 +1330,39 @@
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// If a host fragment is in the URL, expand and scroll to that host;
// otherwise expand the first host as before.
// Fetch glance data for every host immediately so the strip is always populated.
document.querySelectorAll('.host-card').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
// Expand and load info for the target host (URL hash or first host).
function expandHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (!card) return false;
card.classList.remove('collapsed');
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>';
});
return true;
}
const hash = window.location.hash;
if (hash) {
const hostname = decodeURIComponent(hash.slice(1));
if (expandHost(hostname)) {
setTimeout(() => {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) {
card.classList.remove('collapsed');
fetchHostGlance(hostname);
setTimeout(() => card.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 150);
return;
}
}
const first = document.querySelector('.host-card');
if (first) {
first.classList.remove('collapsed');
fetchHostGlance(first.dataset.hostname);
}
if (first) expandHost(first.dataset.hostname);
});
// ── Host action helpers ──────────────────────────────────────
+319 -23
View File
@@ -215,11 +215,59 @@
.save-row { display: flex; align-items: center; margin-top: 8px; }
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
.btn-save:hover { background: #0055aa; }
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
.channel-item:last-child { border-bottom: none; }
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
.channel-item .ch-name { font-weight: 500; color: #222; }
.channel-item .ch-meta { font-size: .8em; color: #888; }
/* ---- Channel chip picker ---- */
.ch-picker { }
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
.ch-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
border: none; font-family: inherit;
}
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
.ch-chip.selected:hover { background: #bbdefb; }
.ch-chip.available { background: #f1f3f4; color: #555; }
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
/* ---- My Channels card list ---- */
.my-ch-card {
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
}
.my-ch-header {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
}
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
.btn-sm-edit:hover { background: #666; }
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
.btn-sm-del:hover { background: #fce4ec; }
/* ---- Channel modal (for My Channels CRUD) ---- */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-modal-status { font-size: .83em; margin-top: 8px; }
</style>
<body>
@@ -318,37 +366,117 @@
</div>
{% endif %}
<!-- Notification channels -->
<!-- Notification channels — chip picker -->
<div class="section">
<h2>Notification Channels</h2>
{% if current_user %}
<p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
{% if all_channel_names %}
<div id="channel-checkboxes">
{% for ch_name in all_channel_names %}
<div class="channel-item">
<label>
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
<div>
<div class="ch-name">{{ ch_name | e }}</div>
</div>
</label>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
{% if all_channels %}
<div class="ch-picker">
<div class="ch-picker-label">Selected</div>
<div id="selected-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name in (current_user.notification_channels or []) %}
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
{{ ch.name | e }} <span class="ch-chip-x">×</span>
</button>
{% endif %}
{% endfor %}
{% set selected_set = current_user.notification_channels or [] %}
{% set has_selected = selected_set | length > 0 %}
{% if not has_selected %}
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
{% endif %}
</div>
<div class="ch-picker-label">Available</div>
<div id="available-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name not in (current_user.notification_channels or []) %}
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
+ {{ ch.name | e }}
</button>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels configured.</p>
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
{% endif %}
<div class="save-row" style="margin-top:10px">
<div class="save-row">
<button class="btn-save" onclick="saveChannels()">Save channels</button>
<span id="channels-status" class="status-msg"></span>
</div>
{% else %}
<span class="no-hosts">No personal notification channels configured.</span>
<span class="no-hosts">Log in to manage notification channels.</span>
{% endif %}
</div>
<!-- My Channels — create/edit/delete own channels -->
{% if current_user %}
<div class="section">
<h2>My Channels</h2>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
<div id="my-channels-list">
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
{% for ch in my_channels %}
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
<div class="my-ch-header">
<span class="my-ch-name">{{ ch.name | e }}</span>
<span class="my-ch-type">{{ ch.type | e }}</span>
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
<span class="my-ch-actions">
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')"></button>
</span>
</div>
</div>
{% endfor %}
{% if not my_channels %}
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
{% endif %}
</div>
<div class="save-row" style="margin-top:8px">
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
</div>
</div>
<!-- My Channels modal -->
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
<div class="ch-modal-box">
<h3 id="my-ch-modal-title">New Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="my-ch-type" onchange="onMyChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="my-ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<label>Minimum alert level</label>
<select id="my-ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="my-ch-private"> Private — visible only to you
</label>
</div>
<div id="my-ch-modal-status" class="ch-modal-status"></div>
<div class="ch-modal-footer">
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
<button class="btn-save" onclick="saveMyChannel()">Save</button>
</div>
</div>
</div>
{% endif %}
<!-- Host access -->
<div class="section">
<h2>Host Access</h2>
@@ -395,6 +523,7 @@
</div>
<script>
// ---- Identity ----
async function saveIdentity() {
const full_name = document.getElementById('profile-fullname').value;
const avatar = document.getElementById('profile-avatar').value;
@@ -411,6 +540,7 @@
}
}
// ---- Password ----
async function changePassword() {
const current = document.getElementById('profile-current-pw').value;
const newpw = document.getElementById('profile-new-pw').value;
@@ -433,9 +563,37 @@
}
}
// ---- Channel chip picker ----
function toggleChip(btn) {
const name = btn.dataset.ch;
const isSelected = btn.classList.contains('selected');
if (isSelected) {
// Move to available
btn.classList.remove('selected');
btn.classList.add('available');
btn.innerHTML = '+ ' + escHtml(name);
btn.onclick = function() { toggleChip(this); };
document.getElementById('available-chips').appendChild(btn);
// Remove "None selected" placeholder if it exists
} else {
// Move to selected
btn.classList.remove('available');
btn.classList.add('selected');
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
btn.onclick = function() { toggleChip(this); };
document.getElementById('selected-chips').appendChild(btn);
}
// Update placeholder visibility
const sel = document.getElementById('selected-chips');
const placeholder = sel.querySelector('span[style]');
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
}
async function saveChannels() {
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
.map(cb => cb.value);
const notification_channels = [
...document.querySelectorAll('#selected-chips .ch-chip.selected')
].map(b => b.dataset.ch);
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
@@ -449,6 +607,138 @@
}
}
// ---- My Channels CRUD ----
let _myChSchemas = {};
let _myChEditName = null;
async function _loadMyChSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_myChSchemas = await r.json();
const sel = document.getElementById('my-ch-type');
if (!sel) return;
Object.entries(_myChSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onMyChTypeChange() {
const type = document.getElementById('my-ch-type').value;
const container = document.getElementById('my-ch-type-fields');
container.innerHTML = '';
if (!type || !_myChSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _myChSchemas[type].label + ' settings';
container.appendChild(divider);
(_myChSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement('input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'mychf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openMyChModal(name) {
_myChEditName = name || null;
document.getElementById('my-ch-modal-status').textContent = '';
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
document.getElementById('my-ch-name').value = name || '';
document.getElementById('my-ch-name').disabled = !!name;
document.getElementById('my-ch-type').value = '';
document.getElementById('my-ch-type-fields').innerHTML = '';
document.getElementById('my-ch-min-level').value = 'WARNING';
document.getElementById('my-ch-private').checked = false;
if (name) {
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('my-ch-type').value = ch.type;
onMyChTypeChange();
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('my-ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('mychf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel', e); }
}
document.getElementById('my-ch-modal').style.display = 'flex';
}
function closeMyChModal() {
document.getElementById('my-ch-modal').style.display = 'none';
}
async function saveMyChannel() {
const name = document.getElementById('my-ch-name').value.trim();
const type = document.getElementById('my-ch-type').value;
const minLevel = document.getElementById('my-ch-min-level').value;
const isPrivate = document.getElementById('my-ch-private').checked;
const statusEl = document.getElementById('my-ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
if (_myChSchemas[type]) {
(_myChSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('mychf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_myChEditName;
const url = isEdit
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
: '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
closeMyChModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
async function deleteMyChannel(name) {
if (!confirm('Delete channel "' + name + '"?')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Utilities ----
function showStatus(id, msg, color) {
const el = document.getElementById(id);
if (!el) return;
@@ -456,6 +746,12 @@
el.style.color = color;
setTimeout(() => { el.textContent = ''; }, 3000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
</script>
</body>
</html>
+734 -17
View File
@@ -6,7 +6,7 @@
html, body { overflow: visible; }
.container {
max-width: 960px;
max-width: none;
}
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
@@ -207,6 +207,36 @@
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
.channel-field-value { color: #333; word-break: break-all; }
/* ---- Channel management (form-based section) ---- */
.channel-header-actions { margin-left: auto; display: flex; gap: 6px; }
.ch-owner-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8f5e9; color: #2e7d32; }
.ch-private-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.ch-level-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fff3e0; color: #e65100; }
.channel-grid { padding: 12px 20px 0; }
.channel-add-bar { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #f0f0f0; }
/* Channel modal */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-status { font-size: .83em; margin-top: 8px; }
/* ---- Hosts table ---- */
/* ---- Mobile: collapsible sidebar ---- */
.sidebar-toggle {
@@ -268,8 +298,6 @@
/* ---- Editable inputs ---- */
.field-input {
width: 100%;
max-width: 360px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 8px;
@@ -337,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 td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
.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 ---- */
.modal-overlay {
@@ -352,9 +380,88 @@
.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: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>
<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' %}
<div class="container">
@@ -381,6 +488,42 @@
</div>
</div>
<!-- Channel add/edit modal -->
<div id="ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeChannelModal()">
<div class="ch-modal-box">
<h3 id="ch-modal-title">Add Notification Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="ch-type" onchange="onChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<label>Minimum alert level</label>
<select id="ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="ch-private"> Private — visible only to you
</label>
</div>
<div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveChannel()">Save</button>
</div>
</div>
</div>
<div class="settings-layout">
<!-- Sidebar navigation -->
@@ -434,12 +577,8 @@
<td><input class="field-input user-full-name" value="{{ u.full_name | 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="min-width:120px">
{% for ch in all_channel_names %}
<label style="display:block;font-size:.82em;white-space:nowrap">
<input type="checkbox" class="user-ch" value="{{ ch | e }}" {% if ch in u.notification_channels %}checked{% endif %}> {{ ch | e }}
</label>
{% endfor %}
<td style="min-width:140px">
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
</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>
@@ -488,6 +627,167 @@
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
</div>
{# ---- Hosts CRUD table ---- #}
{% elif section.section_mode == 'hosts' %}
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="hosts-editor">
<thead><tr>
<th>Hostname</th>
<th>Watch</th>
<th>DynDNS</th>
<th>Owner</th>
<th style="min-width:110px">Managers</th>
<th style="min-width:110px">Monitors</th>
<th style="min-width:110px">Threshold config</th>
<th style="min-width:110px">Channels</th>
<th></th>
</tr></thead>
<tbody id="hosts-tbody">
{% for h in section.hosts %}
<tr data-host-row="true" data-hostname="{{ h.name | e }}">
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</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><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addHostRow()" style="margin-right:auto">+ Add host</button>
<button class="btn btn-primary" onclick="stageHostsSection()">Stage changes</button>
</div>
{# ---- Notification channels (form-based, live CRUD) ---- #}
{% elif section.section_mode == 'channels' %}
{% 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">
{% if f.type == 'list' %}
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
{% else %}<span class="val-empty">None</span>{% endif %}
{% else %}
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
{% endif %}
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
<div class="channel-grid" id="channel-cards">
{% for ch in section.channels %}
<div class="channel-card" id="chcard-{{ ch.name | e }}">
<div class="channel-header">
<span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
<span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span>
</div>
<div class="channel-fields">
{% for f in ch.fields %}
<div class="channel-field">
<span class="channel-field-label">{{ f.label }}</span>
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc"></span>{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
</div>
<div class="channel-add-bar">
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
</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 ---- #}
{% elif section.section_mode == 'yaml' %}
<div style="padding: 12px 20px">
@@ -554,8 +854,140 @@
</div>{# /container #}
<script>
// ---- Channel names for add-user row ----
// ---- Lookup arrays for CRUD rows ----
const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
// ---- Channel CRUD ----
let _channelSchemas = {};
let _chEditName = null; // null = create mode, string = edit mode
async function _loadChannelSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_channelSchemas = await r.json();
const sel = document.getElementById('ch-type');
if (!sel) return;
Object.entries(_channelSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onChTypeChange() {
const type = document.getElementById('ch-type').value;
const container = document.getElementById('ch-type-fields');
container.innerHTML = '';
if (!type || !_channelSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _channelSchemas[type].label + ' settings';
container.appendChild(divider);
(_channelSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'chf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openChannelModal(name) {
_chEditName = name || null;
document.getElementById('ch-modal-status').textContent = '';
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
document.getElementById('ch-name').value = name || '';
document.getElementById('ch-name').disabled = !!name;
document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false;
if (name) {
// Load existing channel data via API
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('ch-type').value = ch.type;
onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel data', e); }
}
document.getElementById('ch-modal').style.display = 'flex';
}
function closeChannelModal() {
document.getElementById('ch-modal').style.display = 'none';
}
async function saveChannel() {
const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value;
const isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_chEditName;
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
closeChannelModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving channel.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
async function deleteChannel(name) {
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete channel.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Staged changes accumulator ----
const _staged = {};
@@ -566,9 +998,13 @@
if (count > 0) {
document.getElementById('pending-count').textContent = count;
banner.style.display = 'flex';
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
} else {
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) {
@@ -605,7 +1041,7 @@
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
@@ -619,7 +1055,7 @@
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
@@ -663,6 +1099,57 @@
flashStaged('oauth');
}
function stageHostsSection() {
function rowToEntry(row) {
const entry = {
watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked,
};
const owner = row.querySelector('.host-owner').value.trim();
if (owner) entry.owner = owner;
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
if (monitors.length) entry.monitors = monitors;
const tcs = [...(row.querySelector('.host-tc')?.selectedOptions || [])].map(o => o.value);
if (tcs.length) entry.threshold_config = tcs;
const chs = [...(row.querySelector('.host-channels')?.selectedOptions || [])].map(o => o.value);
if (chs.length) entry.notification_channels = chs;
return entry;
}
const hosts = {};
document.querySelectorAll('[data-host-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
hosts[row.dataset.hostname] = rowToEntry(row);
});
document.querySelectorAll('[data-new-host]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const h = (row.querySelector('.new-hostname') || {value: ''}).value.trim();
if (!h) return;
hosts[h] = rowToEntry(row);
});
_staged['hosts'] = hosts;
updatePendingBanner();
flashStaged('hosts');
}
function addHostRow() {
const tbody = document.getElementById('hosts-tbody');
const row = document.createElement('tr');
row.setAttribute('data-new-host', 'true');
row.innerHTML = `
<td><input class="field-input new-hostname" placeholder="hostname" required style="min-width:120px"></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><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
async function publishAll() {
const btn = document.querySelector('[onclick="publishAll()"]');
btn.disabled = true;
@@ -690,6 +1177,7 @@
function discardAll() {
Object.keys(_staged).forEach(k => delete _staged[k]);
localStorage.removeItem('hbd_pending_config');
updatePendingBanner();
window.location.reload();
}
@@ -707,6 +1195,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
_loadChannelSchemas();
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
const sectionId = ta.id.replace('yaml-', '');
const section = document.getElementById(sectionId);
@@ -731,9 +1220,6 @@
function addUserRow() {
const tbody = document.getElementById('users-tbody');
const chHtml = _allChannels.map(ch =>
`<label style="display:block;font-size:.82em;white-space:nowrap"><input type="checkbox" class="user-ch" value="${escHtml(ch)}"> ${escHtml(ch)}</label>`
).join('');
const row = document.createElement('tr');
row.setAttribute('data-new-user', 'true');
row.innerHTML = `
@@ -741,7 +1227,7 @@
<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 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><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
@@ -821,6 +1307,121 @@
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---- 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
const sections = document.querySelectorAll('.section');
const navLinks = document.querySelectorAll('.sidebar-nav a');
@@ -849,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() {
var sidebarNav = document.getElementById('sidebar-nav');
var sidebarToggle = document.getElementById('sidebar-toggle');
+30 -1
View File
@@ -492,7 +492,27 @@ class ThresholdChecker:
raw_overrides: Dict[str, ThresholdConfig] = {}
thresholds_config = config_data["thresholds"]
for plugin_name, plugin_thresholds in thresholds_config.items():
if isinstance(plugin_thresholds, dict):
if not isinstance(plugin_thresholds, dict):
continue
plugin_enabled = plugin_thresholds.get('enabled', plugin_thresholds.get('enable', True))
if not plugin_enabled:
# raw_overrides is empty at this point so there's nothing to delete.
# Instead, inject disabled stubs for every matching effective_default so
# the merge step overwrites the inherited defaults.
for key, tc in effective_defaults.items():
if key.startswith(f"{plugin_name}."):
raw_overrides[key] = ThresholdConfig(
metric_path=key,
warning=tc.warning,
critical=tc.critical,
operator=tc.operator.value,
enabled=False,
)
logger.info(
"Plugin-level disable in config '%s': disabled all thresholds for %s",
config_name, plugin_name,
)
else:
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=raw_overrides)
self.threshold_raw_configs[config_name] = raw_overrides
@@ -571,6 +591,15 @@ class ThresholdChecker:
self._parse_rtt_thresholds(thresholds, target_dict)
return
# Plugin-level enabled: false (also accept 'enable' as a common typo) removes all
# thresholds for this plugin — e.g. memory_monitor: {enabled: false}.
plugin_enabled = thresholds.get('enabled', thresholds.get('enable', True))
if not plugin_enabled:
for key in [k for k in target_dict if k.startswith(f"{plugin_name}.")]:
del target_dict[key]
logger.info("Plugin-level disable: removed all thresholds for %s", plugin_name)
return
for metric_name, threshold_config in thresholds.items():
if not isinstance(threshold_config, dict):
continue
+1 -1
View File
@@ -377,7 +377,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
default_owner = config_mod.get_default_owner(cfg)
inferred_owner = plugin_data.get("owner", config_owner or default_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:
print(f"Stored plugin data for {uname}: {plugin_name}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hbd"
version = "5.3.1"
version = "5.3.4"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md"
requires-python = ">=3.11"
+23 -9
View File
@@ -1264,6 +1264,8 @@ static void usage(const char *prog) {
" -c FILE Config file (JSON)\n"
" -m MSG Send one-shot message\n"
" -n NAME Override hostname\n"
" -4 Use IPv4 only\n"
" -6 Use IPv6 only\n"
" -d Daemonize\n"
" -v Verbose (info)\n"
" -x Debug\n"
@@ -1276,9 +1278,10 @@ int main(int argc, char **argv) {
const char *cfgpath = NULL;
const char *message = NULL;
const char *nameov = NULL;
int af_filter = 0;
int opt;
while ((opt = getopt(argc, argv, "bc:m:n:dvxh")) != -1) {
while ((opt = getopt(argc, argv, "bc:m:n:dvxh46")) != -1) {
switch (opt) {
case 'b': do_boot = true; break;
case 'c': cfgpath = optarg; break;
@@ -1287,6 +1290,8 @@ int main(int argc, char **argv) {
case 'd': do_daemon = true; break;
case 'v': g_log_level = LL_INFO; break;
case 'x': g_log_level = LL_DEBUG; break;
case '4': af_filter = AF_INET; break;
case '6': af_filter = AF_INET6; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
@@ -1313,14 +1318,24 @@ int main(int argc, char **argv) {
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
}
struct sigaction sa = {0};
sa.sa_handler = sig_handler;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
int conn_id = 1;
int retry_delay = 5;
while (g_running && !g_nconns) {
for (int i = 0; i < nhost; i++) {
struct addrinfo hints = {0}, *res = NULL;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_protocol = IPPROTO_UDP;
hints.ai_family = af_filter;
char ps[16]; snprintf(ps, sizeof(ps), "%d", cfg.hb_port);
if (getaddrinfo(hosts[i], ps, &hints, &res) != 0) {
LOGE("cannot resolve %s", hosts[i]); continue;
LOGW("cannot resolve %s — retrying in %ds", hosts[i], retry_delay);
continue;
}
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
conn_t *c = &g_conns[g_nconns];
@@ -1336,13 +1351,12 @@ int main(int argc, char **argv) {
}
freeaddrinfo(res);
}
if (!g_nconns) { LOGE("no connections established"); return 1; }
struct sigaction sa = {0};
sa.sa_handler = sig_handler;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
if (!g_nconns) {
sleep(retry_delay);
if (retry_delay < 60) retry_delay *= 2;
}
}
if (!g_nconns) return 1;
conn_t *primary = &g_conns[0];
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
+15 -4
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.3.1"
__version__ = "5.3.4"
# ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py)
@@ -1059,22 +1059,30 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
else 0)
connections: List[AsyncConnection] = []
conn_id = 1
_retry_delay = 5
while _running and not connections:
for host in args.hosts:
try:
addrs = socket.getaddrinfo(host, port, 0, 0, socket.SOL_UDP)
addrs = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
except socket.gaierror as e:
log.error("cannot resolve %s: %s", host, e)
log.warning("cannot resolve %s: %s — retrying in %ds", host, e, _retry_delay)
continue
for ai in addrs:
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
if await conn.open():
connections.append(conn)
conn_id += 1
if not connections:
await _sleep(_retry_delay)
_retry_delay = min(_retry_delay * 2, 60)
if not connections:
log.error("no connections established")
return 1
# Boot / one-shot message
@@ -1153,6 +1161,9 @@ def main(argv=None):
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
af_group = parser.add_mutually_exclusive_group()
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
args = parser.parse_args(argv)
+1 -1
View File
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
import hbdclass
ctx = {
"config": {"watchhosts": [], "dyndnshosts": []},
"config": {"watchhosts": []},
"hbdclass": hbdclass,
"log": dummy_noop,
"email": dummy_noop,
+38
View File
@@ -83,3 +83,41 @@ def test_put_users_me_notification_channels(tmp_path):
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
def test_visible_channels_excludes_private_from_others():
"""Private channels owned by another user must not appear in the visible set."""
from hbd.server import settings as settings_mod
config = {
"notification_channels": {
"public_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"},
"bob_priv": {"type": "email", "owner": "bob", "private": True,
"recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"},
}
}
class FakeUser:
def __init__(self, username, admin=False):
self.username = username
self.admin = admin
alice = FakeUser("alice")
bob = FakeUser("bob")
admin = FakeUser("admin", admin=True)
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
def visible(user):
all_channels = config.get("notification_channels") or {}
if user.admin:
return set(all_channels.keys())
return {
name for name, cfg in all_channels.items()
if not cfg.get("private") or cfg.get("owner") == user.username
}
assert visible(alice) == {"public_ch", "alice_priv"}
assert visible(bob) == {"public_ch", "bob_priv"}
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
+178
View File
@@ -0,0 +1,178 @@
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
import pytest
from hbd.server import configio, settings as settings_mod
SAMPLE_YAML = """\
hbd_port: 50004
notification_channels:
pushover_ops:
type: pushover
token: abc123
user: usr456
"""
# ---------------------------------------------------------------------------
# configio helpers
# ---------------------------------------------------------------------------
def test_apply_channel_adds_new_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]})
assert "email_ops" in data["notification_channels"]
assert data["notification_channels"]["email_ops"]["type"] == "email"
# Existing channel preserved
assert "pushover_ops" in data["notification_channels"]
def test_apply_channel_updates_existing(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"})
assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok"
def test_apply_channel_creates_section_if_absent():
data = {"hbd_port": 50004}
configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"})
assert "notification_channels" in data
assert "test_ch" in data["notification_channels"]
def test_delete_channel_removes_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
assert "pushover_ops" not in data["notification_channels"]
def test_delete_channel_noop_for_missing():
data = {"notification_channels": {"ch1": {"type": "pushover"}}}
configio.delete_channel(data, "nonexistent") # must not raise
assert "ch1" in data["notification_channels"]
def test_delete_channel_noop_when_no_section():
data = {}
configio.delete_channel(data, "anything") # must not raise
def test_apply_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"})
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "signal_ops" in result["notification_channels"]
assert result["notification_channels"]["signal_ops"]["user"] == "+1"
# Original channel preserved
assert "pushover_ops" in result["notification_channels"]
def test_delete_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "pushover_ops" not in (result.get("notification_channels") or {})
# ---------------------------------------------------------------------------
# Visibility logic (mirrors http.py _visible_channels_for_user)
# ---------------------------------------------------------------------------
def _visible(config, user):
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
all_channels = config.get("notification_channels") or {}
if user.get("admin"):
return set(all_channels.keys())
username = user["username"]
return {
name for name, cfg in all_channels.items()
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
}
CONFIG_VISIBILITY = {
"notification_channels": {
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
"user": "+1", "recipient": "+2"},
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
}
}
def test_public_channel_visible_to_all():
for uname in ("alice", "bob", "carol"):
user = {"username": uname, "admin": False}
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
def test_private_channel_visible_only_to_owner():
alice = {"username": "alice", "admin": False}
bob = {"username": "bob", "admin": False}
carol = {"username": "carol", "admin": False}
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
def test_admin_sees_all_channels():
admin = {"username": "adminuser", "admin": True}
visible = _visible(CONFIG_VISIBILITY, admin)
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
def test_admin_owned_channel_is_public_by_default():
alice = {"username": "alice", "admin": False}
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
# ---------------------------------------------------------------------------
# Channel type schemas
# ---------------------------------------------------------------------------
def test_all_required_types_in_schema():
for t in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert t in settings_mod.CHANNEL_TYPE_SCHEMAS
def test_schema_fields_have_required_keys():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert "label" in schema, f"{type_id} missing label"
assert "fields" in schema, f"{type_id} missing fields"
for f in schema["fields"]:
for k in ("key", "label", "type", "required"):
assert k in f, f"{type_id} field missing {k!r}"
def test_secret_fields_use_secret_type():
"""Known secret fields must be typed 'secret' so the UI masks them."""
secret_keys = {"token", "user_key", "api_key", "api_password",
"smtp_password", "access_token"}
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
for f in schema["fields"]:
if f["key"] in secret_keys:
assert f["type"] == "secret", (
f"{type_id}.{f['key']} should be type 'secret'"
)
def test_channel_labels_not_empty():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert schema["label"].strip(), f"{type_id} has empty label"
+36 -5
View File
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml")
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
def test_sections_have_api_section():
@@ -45,16 +45,47 @@ def test_network_section_has_editable_fields():
def test_yaml_sections_have_correct_mode():
sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" in yaml_sections
assert "hosts" in yaml_sections
assert "channels" not in yaml_sections # now uses "channels" mode
assert "hosts" not in yaml_sections # now uses "hosts" mode
assert "thresholds" in yaml_sections
assert "dns" in yaml_sections
assert yaml_sections["channels"]["api_section"] == "notification_channels"
assert yaml_sections["hosts"]["api_section"] == "hosts"
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns"
def test_hosts_section_uses_hosts_mode():
sections = settings_mod.get_settings_sections(CFG)
hosts_sec = next(s for s in sections if s["id"] == "hosts")
assert hosts_sec["section_mode"] == "hosts"
assert hosts_sec["api_section"] == "hosts"
def test_channels_section_uses_channels_mode():
sections = settings_mod.get_settings_sections(CFG)
ch_sec = next(s for s in sections if s["id"] == "channels")
assert ch_sec["section_mode"] == "channels"
assert ch_sec["api_section"] == "notification_channels"
assert len(ch_sec["channels"]) == 1
ch = ch_sec["channels"][0]
assert ch["name"] == "pushover_ops"
assert ch["type"] == "pushover"
assert "owner" in ch
assert "private" in ch
def test_channel_type_schemas_exported():
assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS")
for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type]
assert "label" in schema
assert "fields" in schema
for f in schema["fields"]:
assert "key" in f
assert "type" in f
assert "required" in f
def test_oauth_section_exists():
sections = settings_mod.get_settings_sections(CFG)
oauth = next((s for s in sections if s["id"] == "oauth"), None)