Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00282913b | |||
| d699a29fa9 | |||
| 4ce7eacfdd | |||
| 1cefc2676e | |||
| 668a135e53 | |||
| 59e256a042 | |||
| 708508157f | |||
| f67fa9baff | |||
| 588eb2a792 | |||
| b907343e36 | |||
| e50a3996ae | |||
| e1056a0365 | |||
| 1dbe0f8e64 | |||
| 12e8812070 | |||
| 9b5d8ac9b1 | |||
| 500d256d76 | |||
| a7a45bf8c3 | |||
| 3e9b052f71 | |||
| 7444262985 | |||
| 3401cc0dbb | |||
| ab0132a38d | |||
| 9e389736f8 | |||
| b64a2a9313 | |||
| a52744a448 | |||
| 5e2b04b811 | |||
| 8e07b09d7e | |||
| 653e018e4f | |||
| c7326da7d9 | |||
| 0426a75d8c | |||
| 539f25d877 |
@@ -8,7 +8,7 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
|||||||
|
|
||||||
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅
|
||||||
- Maintain host state and detect up/down transitions ✅
|
- Maintain host state and detect up/down transitions ✅
|
||||||
- Queue DNS updates via `nsupdate` and run them in a background thread ✅
|
- Queue DNS updates via `nsupdate` and run them in an asyncio background task ✅
|
||||||
- WebSocket API for live updates (hosts & messages) ✅
|
- WebSocket API for live updates (hosts & messages) ✅
|
||||||
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅
|
||||||
- **User management & access control** ✅
|
- **User management & access control** ✅
|
||||||
@@ -398,6 +398,7 @@ hosts:
|
|||||||
owner: alice
|
owner: alice
|
||||||
managers: [bob]
|
managers: [bob]
|
||||||
monitors: [carol]
|
monitors: [carol]
|
||||||
|
dyndns: true # update DNS record when IP changes
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -645,7 +646,7 @@ Set breakpoints in modules such as `hbd/server/udp.py`, `hbd/server/dns.py`, or
|
|||||||
- `logfile`: path to log file
|
- `logfile`: path to log file
|
||||||
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
- `pushsrv`: push service (`pushover`|`mattermost`|`all`)
|
||||||
- `interval` / `grace`: heartbeat timing configuration
|
- `interval` / `grace`: heartbeat timing configuration
|
||||||
- `dyndomains`: list of dyndomains to update via `nsupdate`
|
- `dyndomains`: list of DNS domains to update via `nsupdate` for hosts with `dyndns` set
|
||||||
- `nsupdate_bin`: path to nsupdate binary
|
- `nsupdate_bin`: path to nsupdate binary
|
||||||
- `ws_port`: port for plain WebSocket connections (default: 50005)
|
- `ws_port`: port for plain WebSocket connections (default: 50005)
|
||||||
- `wss_port`: port for secure WebSocket (WSS) connections (default: none).
|
- `wss_port`: port for secure WebSocket (WSS) connections (default: none).
|
||||||
@@ -666,6 +667,9 @@ dyndomains:
|
|||||||
- example.com
|
- example.com
|
||||||
nsupdate_bin: /usr/bin/nsupdate
|
nsupdate_bin: /usr/bin/nsupdate
|
||||||
pushsrv: pushover
|
pushsrv: pushover
|
||||||
|
hosts:
|
||||||
|
myhost:
|
||||||
|
dyndns: true # update DNS when this host's IP changes
|
||||||
```
|
```
|
||||||
|
|
||||||
> Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
|
> Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
|
||||||
@@ -769,10 +773,3 @@ Contributions welcome! Please:
|
|||||||
This repository is licensed under the MIT license. See `LICENSE` for details.
|
This repository is licensed under the MIT license. See `LICENSE` for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
If you'd like, I can also:
|
|
||||||
|
|
||||||
- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁
|
|
||||||
- add a `CONTRIBUTING.md` template for PRs and code style 💬
|
|
||||||
|
|
||||||
Which one should I do next? ✨
|
|
||||||
|
|||||||
@@ -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` | List all users | Admin |
|
||||||
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
|
| `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
|
### 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
|
### Alert Endpoints
|
||||||
|
|
||||||
#### GET /api/0/hosts/{hostname}/alerts
|
#### GET /api/0/hosts/{hostname}/alerts
|
||||||
|
|||||||
+37
-7
@@ -30,9 +30,17 @@ Set `base_url` so notification links point to your hbd instance:
|
|||||||
base_url: https://hbd.example.com
|
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
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -41,7 +49,7 @@ notification_channels:
|
|||||||
type: pushover
|
type: pushover
|
||||||
token: your-app-token
|
token: your-app-token
|
||||||
user: your-user-key
|
user: your-user-key
|
||||||
min_level: WARNING # optional, default: WARNING
|
min_level: WARNING
|
||||||
|
|
||||||
email_ops:
|
email_ops:
|
||||||
type: email
|
type: email
|
||||||
@@ -58,14 +66,14 @@ notification_channels:
|
|||||||
homeserver: https://matrix.example.org
|
homeserver: https://matrix.example.org
|
||||||
access_token: syt_xxx
|
access_token: syt_xxx
|
||||||
room_id: "!abc:matrix.example.org"
|
room_id: "!abc:matrix.example.org"
|
||||||
min_level: CRITICAL # only send critical alerts to this room
|
min_level: CRITICAL
|
||||||
|
|
||||||
sms_oncall:
|
sms_oncall:
|
||||||
type: sms_voipms
|
type: sms_voipms
|
||||||
api_user: me@example.com
|
api_user: me@example.com
|
||||||
api_password: secret
|
api_password: secret
|
||||||
did: "5551234567" # your voip.ms DID number
|
did: "5551234567"
|
||||||
dst: "5559876543" # destination number
|
dst: "5559876543"
|
||||||
min_level: CRITICAL
|
min_level: CRITICAL
|
||||||
|
|
||||||
signal_ops:
|
signal_ops:
|
||||||
@@ -82,9 +90,30 @@ notification_channels:
|
|||||||
username: heartbeat-bot
|
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
|
### 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
|
```yaml
|
||||||
users:
|
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 the host has an `owner` or `managers` set
|
||||||
- Check that users have `notification_channels` listed
|
- Check that users have `notification_channels` listed
|
||||||
- Check that the channel names in user config match keys under `notification_channels:`
|
- 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:**
|
**min_level filtering too aggressive:**
|
||||||
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
||||||
|
|||||||
+27
-1
@@ -36,7 +36,7 @@ users:
|
|||||||
bob:
|
bob:
|
||||||
full_name: Bob Smith
|
full_name: Bob Smith
|
||||||
password: pbkdf2:sha256:...
|
password: pbkdf2:sha256:...
|
||||||
notification_channels: [pushover_standard]
|
notification_channels: [pushover_standard] # channels bob has selected
|
||||||
|
|
||||||
carol:
|
carol:
|
||||||
full_name: Carol Jones
|
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
|
### Host Access
|
||||||
|
|
||||||
#### GET /api/0/hosts/{hostname}/access
|
#### GET /api/0/hosts/{hostname}/access
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
# Host Overview Info Section — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an always-visible info section to each host card on `/plugins`, showing owner, managers, agent version/type, last packet timestamp, and effective thresholds; move hbc_version/hbc_type out of the os_info accordion.
|
||||||
|
|
||||||
|
**Architecture:** A new `_build_host_info` module-level helper in `http.py` assembles the info dict from the host object and threshold_checker. A new `GET /api/0/hosts/{hostname}/info` closure inside `serve()` calls it and returns JSON. The `plugins.html` template adds a static placeholder div per host; JS fetches the endpoint on first card expand, caches the result, and renders it.
|
||||||
|
|
||||||
|
**Tech Stack:** Python/aiohttp (backend), Jinja2 (template), vanilla JS/HTML/CSS (frontend). Tests with pytest and unittest.mock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `_build_host_info` helper — tests first
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_http_host_info.py`
|
||||||
|
- Modify: `hbd/server/http.py` (add module-level helper after `_mask_config_for_api`, around line 128)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/test_http_host_info.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
def __init__(self, lastbeat):
|
||||||
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHost:
|
||||||
|
def __init__(self, name="myhost", owner=None, managers=None,
|
||||||
|
connections=None, os_data=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = managers or []
|
||||||
|
self.connections = connections or {}
|
||||||
|
self._os_data = os_data
|
||||||
|
|
||||||
|
def get_latest_plugin_data(self, plugin_name):
|
||||||
|
if plugin_name == "os_info" and self._os_data is not None:
|
||||||
|
return (1234567890.0, self._os_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_basic_fields():
|
||||||
|
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] == "alice"
|
||||||
|
assert result["managers"] == ["bob", "carol"]
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_no_owner():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] is None
|
||||||
|
assert result["managers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_reads_hbc_from_os_info():
|
||||||
|
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] == "5.3.0"
|
||||||
|
assert result["hbc_type"] == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_hbc_none_when_no_os_info():
|
||||||
|
host = _FakeHost(os_data=None)
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConn(1000.0),
|
||||||
|
"IPv6": _FakeConn(2000.0),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_none_when_no_connections():
|
||||||
|
host = _FakeHost(connections={})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_none_without_checker():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=None)
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_sorted_by_metric():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||||
|
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||||
|
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {
|
||||||
|
"memory_monitor.memory_percent": tc_mem,
|
||||||
|
"cpu_monitor.cpu_percent": tc_cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
assert len(result["thresholds"]) == 2
|
||||||
|
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||||
|
assert result["thresholds"][0]["warning"] == 80.0
|
||||||
|
assert result["thresholds"][0]["critical"] == 95.0
|
||||||
|
assert result["thresholds"][0]["operator"] == ">"
|
||||||
|
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_threshold_null_warning_critical():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["warning"] is None
|
||||||
|
assert result["thresholds"][0]["critical"] == 500.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_http_host_info.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `ImportError` or `AttributeError` — `_build_host_info` does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `_build_host_info` in `hbd/server/http.py`**
|
||||||
|
|
||||||
|
Insert after `_mask_config_for_api` (around line 128, before `def serve(`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_host_info(host, threshold_checker=None):
|
||||||
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
|
hbc_version = None
|
||||||
|
hbc_type = None
|
||||||
|
latest_os = host.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
_, os_data = latest_os
|
||||||
|
hbc_version = os_data.get("hbc_version")
|
||||||
|
hbc_type = os_data.get("hbc_type")
|
||||||
|
|
||||||
|
last_packet = None
|
||||||
|
if host.connections:
|
||||||
|
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||||
|
|
||||||
|
thresholds = None
|
||||||
|
if threshold_checker is not None:
|
||||||
|
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||||
|
thresholds = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
}
|
||||||
|
for tc in raw.values()
|
||||||
|
],
|
||||||
|
key=lambda x: x["metric"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": getattr(host, "owner", None),
|
||||||
|
"managers": list(getattr(host, "managers", [])),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
|
"hbc_type": hbc_type,
|
||||||
|
"last_packet": last_packet,
|
||||||
|
"thresholds": thresholds,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_http_host_info.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 11 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_http_host_info.py hbd/server/http.py
|
||||||
|
git commit -m "feat: add _build_host_info helper for host info endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `api_host_info` route handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `hbd/server/http.py`
|
||||||
|
- Add `api_host_info` closure inside `serve()` (after `api_host_access_put`, around line 829)
|
||||||
|
- Register route (around line 1271)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `api_host_info` closure inside `serve()`**
|
||||||
|
|
||||||
|
Insert after `api_host_access_put` (after line 829, before the comment `# User profile page`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Host info endpoint
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def api_host_info(request):
|
||||||
|
"""GET /api/0/hosts/{hostname}/info"""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
hostname = request.match_info.get("hostname")
|
||||||
|
if hostname not in hbdclass.Host.hosts:
|
||||||
|
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||||
|
host = hbdclass.Host.hosts[hostname]
|
||||||
|
if not _can_view_host(user, host):
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the route**
|
||||||
|
|
||||||
|
In the route list (around line 1271, after the existing `/api/0/hosts/{hostname}/access` routes):
|
||||||
|
|
||||||
|
```python
|
||||||
|
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the full test suite still passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS (no regressions).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Smoke-test the endpoint manually** (if a dev server is running)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:50004/api/0/hosts/<hostname>/info | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON with `owner`, `managers`, `hbc_version`, `hbc_type`, `last_packet`, `thresholds` keys.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add hbd/server/http.py
|
||||||
|
git commit -m "feat: add GET /api/0/hosts/{hostname}/info endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Info section HTML and CSS in `plugins.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `hbd/server/templates/plugins.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add CSS for the info section**
|
||||||
|
|
||||||
|
In the `<style>` block (find the closing `</style>` tag around line 391 and insert before it):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Host info section ──────────────────────────────────────────────────── */
|
||||||
|
.host-info-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.info-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 3px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
||||||
|
.info-value { color: #222; }
|
||||||
|
.info-thresholds-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.info-note { color: #888; font-style: italic; }
|
||||||
|
.info-loading { color: #bbb; font-style: italic; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add info section placeholder to each host card**
|
||||||
|
|
||||||
|
Inside the host loop, at the very start of `.host-body` (before the `{% set plugin_order %}` line, around line 438):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="host-body">
|
||||||
|
<div class="host-info-section" id="info-{{ host.name }}">
|
||||||
|
<div class="info-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `{% set plugin_order %}` line and everything after stays unchanged. Only add the two new lines between `<div class="host-body">` and `{% set plugin_order %}`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the page still renders without JS errors**
|
||||||
|
|
||||||
|
Start the dev server and open `/plugins` in a browser. Expand any host card — you should see the "Loading…" italic line above the plugin accordions (it will not be replaced yet, that comes in Task 4).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add hbd/server/templates/plugins.html
|
||||||
|
git commit -m "feat: add host info section placeholder and CSS to plugins.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: JS — `infoCache`, `fetchHostInfo`, `renderInfoSection`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `hbd/server/templates/plugins.html` (JS `<script>` block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `infoCache` constant**
|
||||||
|
|
||||||
|
After the `pluginCache` declaration (after `const pluginCache = {};`, around line 489), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
||||||
|
const infoCache = {};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `fetchHostInfo` function**
|
||||||
|
|
||||||
|
After the existing `fetchPlugin` function (around line 522, before `fetchHostGlance`), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function fetchHostInfo(hostname) {
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `renderInfoSection` function**
|
||||||
|
|
||||||
|
After `fetchHostInfo` (before `fetchHostGlance`), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderInfoSection(hostname, data) {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const owner = data.owner ? escHtml(data.owner) : '—';
|
||||||
|
const managers = data.managers && data.managers.length
|
||||||
|
? data.managers.map(escHtml).join(', ') : '—';
|
||||||
|
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
||||||
|
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
||||||
|
const lastPkt = data.last_packet
|
||||||
|
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
||||||
|
|
||||||
|
let html = `<div class="info-meta">
|
||||||
|
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
||||||
|
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
||||||
|
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
||||||
|
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
||||||
|
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (data.thresholds === null) {
|
||||||
|
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
||||||
|
} else if (data.thresholds.length === 0) {
|
||||||
|
html += `<div class="info-note">No thresholds defined.</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
for (const t of data.thresholds) {
|
||||||
|
const w = t.warning !== null && t.warning !== undefined ? t.warning : '—';
|
||||||
|
const c = t.critical !== null && t.critical !== undefined ? t.critical : '—';
|
||||||
|
html += `<tr>
|
||||||
|
<td class="key">${escHtml(t.metric)}</td>
|
||||||
|
<td>${escHtml(t.operator)}</td>
|
||||||
|
<td>${w}</td>
|
||||||
|
<td>${c}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add hbd/server/templates/plugins.html
|
||||||
|
git commit -m "feat: add fetchHostInfo and renderInfoSection JS functions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire `fetchHostInfo` into `toggleHost`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `hbd/server/templates/plugins.html` (the `toggleHost` function, around line 643)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `toggleHost` with the updated version**
|
||||||
|
|
||||||
|
Find the existing `toggleHost` function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function toggleHost(hostname) {
|
||||||
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
|
const wasCollapsed = card.classList.contains('collapsed');
|
||||||
|
card.classList.toggle('collapsed');
|
||||||
|
if (wasCollapsed && !pluginCache[hostname]) {
|
||||||
|
fetchHostGlance(hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function toggleHost(hostname) {
|
||||||
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
|
const wasCollapsed = card.classList.contains('collapsed');
|
||||||
|
card.classList.toggle('collapsed');
|
||||||
|
if (wasCollapsed) {
|
||||||
|
if (!pluginCache[hostname]) {
|
||||||
|
fetchHostGlance(hostname);
|
||||||
|
}
|
||||||
|
if (!infoCache[hostname]) {
|
||||||
|
const infoEl = document.getElementById(`info-${hostname}`);
|
||||||
|
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test in browser**
|
||||||
|
|
||||||
|
Open `/plugins`, expand a host card. Verify:
|
||||||
|
- The info section appears above the plugin accordions.
|
||||||
|
- Owner, managers (or "—"), agent version, agent type, last packet render correctly.
|
||||||
|
- Threshold table renders (or the appropriate "not configured" / "none defined" message).
|
||||||
|
- Collapsing and re-expanding does not re-fetch (no second network request).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add hbd/server/templates/plugins.html
|
||||||
|
git commit -m "feat: fetch and render host info section on card expand"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Remove `hbc_version` and `hbc_type` from `renderOsInfoTable`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `hbd/server/templates/plugins.html` (the `renderOsInfoTable` function, around line 794)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `renderOsInfoTable`**
|
||||||
|
|
||||||
|
Find the existing function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderOsInfoTable(d) {
|
||||||
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
|
'processor','architecture','node','python_version',
|
||||||
|
'python_implementation','hbc_version',
|
||||||
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const shown = new Set(ORDER);
|
||||||
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderOsInfoTable(d) {
|
||||||
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
|
'processor','architecture','node','python_version',
|
||||||
|
'python_implementation',
|
||||||
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||||
|
const shown = new Set(ORDER);
|
||||||
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Expand a host card, then expand the "Os Info" accordion. Confirm:
|
||||||
|
- `hbc_version` no longer appears in the os_info table.
|
||||||
|
- `hbc_type` no longer appears in the os_info table.
|
||||||
|
- Both values are shown correctly in the info section at the top.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add hbd/server/templates/plugins.html
|
||||||
|
git commit -m "feat: move hbc_version and hbc_type out of os_info into host info section"
|
||||||
|
```
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Host Overview Info Section
|
||||||
|
|
||||||
|
**Date:** 2026-05-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an always-visible info section to each host card on the Host Overview (`/plugins`) page. The section shows owner, managers, agent version/type, last packet timestamp, and the host's effective alert thresholds. The fields `hbc_version` and `hbc_type` are moved out of the `os_info` plugin accordion into this section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend: New API Endpoint
|
||||||
|
|
||||||
|
**Route:** `GET /api/0/hosts/{hostname}/info`
|
||||||
|
|
||||||
|
**Auth:** Same as other per-host endpoints (`_can_view_host`).
|
||||||
|
|
||||||
|
**Response schema:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "alice",
|
||||||
|
"managers": ["bob", "carol"],
|
||||||
|
"hbc_version": "5.3.0",
|
||||||
|
"hbc_type": "full",
|
||||||
|
"last_packet": 1746894000.0,
|
||||||
|
"thresholds": [
|
||||||
|
{
|
||||||
|
"metric": "cpu_monitor.cpu_percent",
|
||||||
|
"warning": 80.0,
|
||||||
|
"critical": 95.0,
|
||||||
|
"operator": ">"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field details:**
|
||||||
|
|
||||||
|
- `owner` — `host.owner`, or `null` if unset.
|
||||||
|
- `managers` — `host.managers` list (may be empty).
|
||||||
|
- `hbc_version` — from `host.get_latest_plugin_data("os_info")`, key `hbc_version`; `null` if no os_info data.
|
||||||
|
- `hbc_type` — same source, key `hbc_type`; `null` if unavailable.
|
||||||
|
- `last_packet` — `max(conn.lastbeat for conn in host.connections.values())`, or `null` if no connections.
|
||||||
|
- `thresholds` — list derived from `threshold_checker.get_thresholds_for_host(hostname)`, sorted by `metric` ascending. Each entry includes `metric`, `warning` (null if unset), `critical` (null if unset), `operator`. Returns `null` (not `[]`) if no `threshold_checker` is configured, so the frontend can distinguish "not configured" from "configured but empty".
|
||||||
|
|
||||||
|
**Location:** `hbd/server/http.py`, added alongside the other `api_host_*` functions. Registered as `web.get("/api/0/hosts/{hostname}/info", api_host_info)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend: Info Section
|
||||||
|
|
||||||
|
### HTML structure
|
||||||
|
|
||||||
|
Inserted as the first child of `.host-body`, before the plugin accordions. It is not a collapsible accordion — it is always visible when the host card is expanded.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="host-info-section" id="info-{hostname}">
|
||||||
|
<div class="loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch lifecycle
|
||||||
|
|
||||||
|
- Fetched once per host on the first expansion of the host card (same trigger as the glance/plugin data).
|
||||||
|
- Result cached in a new per-host `infoCache` object (parallel to `pluginCache`).
|
||||||
|
- On subsequent expansions the cached data is rendered immediately without a new request.
|
||||||
|
|
||||||
|
### Rendered layout
|
||||||
|
|
||||||
|
Two logical areas rendered client-side from the JSON:
|
||||||
|
|
||||||
|
**Meta row** — a CSS-grid or simple `<dl>` showing:
|
||||||
|
|
||||||
|
| Label | Value |
|
||||||
|
|---------------|------------------------------|
|
||||||
|
| Owner | alice (or "—" if null) |
|
||||||
|
| Managers | bob, carol (or "—" if empty) |
|
||||||
|
| Agent Version | 5.3.0 (or "—") |
|
||||||
|
| Agent Type | full (or "—") |
|
||||||
|
| Last Packet | localized datetime string (or "—") |
|
||||||
|
|
||||||
|
**Threshold table** — rendered with the existing `data-table` CSS class:
|
||||||
|
|
||||||
|
| Metric | Operator | Warning | Critical |
|
||||||
|
|--------|----------|---------|----------|
|
||||||
|
| cpu_monitor.cpu_percent | > | 80 | 95 |
|
||||||
|
| … | … | … | … |
|
||||||
|
|
||||||
|
- If `thresholds` is `null`: show "Threshold alerting not configured."
|
||||||
|
- If `thresholds` is `[]`: show "No thresholds defined."
|
||||||
|
- Numeric threshold values rendered as-is (no units); `null` warning/critical shown as "—".
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
New `.host-info-section` styles added in the `<style>` block of `plugins.html`. The section gets a subtle background (e.g. `#fafafa`) and a bottom border to separate it visually from the plugin accordions below. The meta row uses a two-column grid layout for compactness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes to `renderOsInfoTable()`
|
||||||
|
|
||||||
|
- Remove `hbc_version` from the `ORDER` array.
|
||||||
|
- Add `hbc_type` to the `SKIP_FIELDS` set (or the local `shown` set) so it is excluded from the os_info table.
|
||||||
|
|
||||||
|
Both fields will now appear only in the info section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
User expands host card
|
||||||
|
→ toggleHost()
|
||||||
|
→ fetchGlanceData(hostname) [existing, unchanged]
|
||||||
|
→ fetchInfoData(hostname) [new]
|
||||||
|
GET /api/0/hosts/{hostname}/info
|
||||||
|
→ renderInfoSection(hostname, data)
|
||||||
|
→ writes into #info-{hostname}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the info fetch fails (non-200), show a one-line error message in the info section ("Could not load host info.").
|
||||||
|
- If `hbc_version`/`hbc_type` are null (host has never sent os_info), display "—".
|
||||||
|
- If `last_packet` is null (no connections recorded), display "—".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Editing owner/managers from this section (covered by existing profile/access UI).
|
||||||
|
- Editing thresholds from this section.
|
||||||
|
- Monitors list (not shown — monitors are operational, not informational in this context).
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.3"
|
||||||
|
|||||||
+33
-18
@@ -518,31 +518,43 @@ async def async_main(args, config):
|
|||||||
|
|
||||||
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
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
|
# Create connections
|
||||||
connections = []
|
connections = []
|
||||||
conn_id = 1
|
conn_id = 1
|
||||||
|
_retry_delay = 5
|
||||||
for host in hb_hosts:
|
|
||||||
try:
|
|
||||||
addrs = socket.getaddrinfo(host, hb_port, 0, 0, socket.SOL_UDP)
|
|
||||||
except socket.gaierror as e:
|
|
||||||
logger.error(f"Cannot resolve {host}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for addr_info in addrs:
|
|
||||||
af = addr_info[0]
|
|
||||||
addr = addr_info[4][0]
|
|
||||||
|
|
||||||
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
while running and not connections:
|
||||||
if not await conn.open():
|
for host in hb_hosts:
|
||||||
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
|
try:
|
||||||
connections.append(conn)
|
addrs = socket.getaddrinfo(host, hb_port, af_filter, 0, socket.SOL_UDP)
|
||||||
conn_id += 1
|
except socket.gaierror as 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:
|
if not connections:
|
||||||
logger.error("No connections established (DNS resolution failed for all hosts)")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(f"Created {len(connections)} connections")
|
logger.info(f"Created {len(connections)} connections")
|
||||||
|
|
||||||
# Send boot/message if requested
|
# Send boot/message if requested
|
||||||
@@ -726,6 +738,9 @@ def build_parser():
|
|||||||
default=0,
|
default=0,
|
||||||
help="Increase debug level"
|
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(
|
parser.add_argument(
|
||||||
"hosts",
|
"hosts",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
|
|||||||
+21
-32
@@ -39,10 +39,8 @@ SERVER_DEFAULTS = {
|
|||||||
|
|
||||||
# Host management
|
# Host management
|
||||||
"hosts": {}, # Unified host definitions
|
"hosts": {}, # Unified host definitions
|
||||||
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
|
||||||
"drophosts": [], # Hosts to ignore
|
|
||||||
"dyndomains": ["wrede.org"],
|
"dyndomains": ["wrede.org"],
|
||||||
|
|
||||||
# DNS updates
|
# DNS updates
|
||||||
"nsupdate_bin": "/usr/bin/nsupdate",
|
"nsupdate_bin": "/usr/bin/nsupdate",
|
||||||
|
|
||||||
@@ -79,9 +77,13 @@ THRESHOLD_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'memory_monitor': {
|
'memory_monitor': {
|
||||||
'percent': {
|
'memory_percent': {
|
||||||
'warning': 85.0,
|
'warning': 85.0,
|
||||||
'critical': 95.0
|
'critical': 95.0
|
||||||
|
},
|
||||||
|
'swap_percent': {
|
||||||
|
'warning': 40.0,
|
||||||
|
'critical': 75.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'disk_monitor': {
|
'disk_monitor': {
|
||||||
@@ -109,11 +111,15 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'pools': {
|
'pools': {
|
||||||
'*': {
|
'*': {
|
||||||
'status': {
|
'status': {
|
||||||
'warning': 1,
|
'warning': 1,
|
||||||
'critical': 2,
|
'critical': 2,
|
||||||
'operator': '>',
|
'operator': '>',
|
||||||
'hysteresis': 0.0,
|
'hysteresis': 0.0,
|
||||||
'display': 'ZFS pool {pool_name} is {health}'
|
'display': 'ZFS pool {pool_name} is {health}'
|
||||||
|
},
|
||||||
|
'capacity': {
|
||||||
|
'warning': 80.0,
|
||||||
|
'critical': 90.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +247,7 @@ def get_watchhosts(config):
|
|||||||
"""Extract watched hostnames from config (hosts with watch: true).
|
"""Extract watched hostnames from config (hosts with watch: true).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of hostnames to watch
|
# List of hostnames to watch
|
||||||
"""
|
"""
|
||||||
watchhosts = []
|
watchhosts = []
|
||||||
hosts_config = config.get("hosts", {})
|
hosts_config = config.get("hosts", {})
|
||||||
@@ -253,31 +259,14 @@ def get_watchhosts(config):
|
|||||||
|
|
||||||
|
|
||||||
def get_dyndnshosts(config):
|
def get_dyndnshosts(config):
|
||||||
"""Extract dyndnshosts from config, supporting both new and legacy formats.
|
"""Return hostnames that have a dyndns setting in the hosts section."""
|
||||||
|
hosts_config = config.get("hosts", {})
|
||||||
Args:
|
if not isinstance(hosts_config, dict):
|
||||||
config: Configuration dictionary
|
return []
|
||||||
|
return [
|
||||||
Returns:
|
name for name, attrs in hosts_config.items()
|
||||||
List of hostnames with dynamic DNS
|
if isinstance(attrs, dict) and attrs.get("dyndns")
|
||||||
"""
|
]
|
||||||
dyndnshosts = []
|
|
||||||
|
|
||||||
# New format: hosts section with dyndns attribute
|
|
||||||
if "hosts" in config:
|
|
||||||
hosts_config = config["hosts"]
|
|
||||||
if isinstance(hosts_config, dict):
|
|
||||||
for host_name, host_attrs in hosts_config.items():
|
|
||||||
if isinstance(host_attrs, dict) and host_attrs.get("dyndns", False):
|
|
||||||
dyndnshosts.append(host_name)
|
|
||||||
|
|
||||||
# Legacy format: dyndnshosts list/set
|
|
||||||
if "dyndnshosts" in config:
|
|
||||||
legacy_dyndnshosts = config.get("dyndnshosts", [])
|
|
||||||
if isinstance(legacy_dyndnshosts, (list, set)):
|
|
||||||
dyndnshosts.extend(legacy_dyndnshosts)
|
|
||||||
|
|
||||||
return list(set(dyndnshosts)) # Remove duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def get_host_config(config, hostname):
|
def get_host_config(config, hostname):
|
||||||
|
|||||||
+17
-1
@@ -21,10 +21,11 @@ _SERVER_KEYS = [
|
|||||||
"interval", "grace", "base_url", "threshold_renotify_interval",
|
"interval", "grace", "base_url", "threshold_renotify_interval",
|
||||||
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
||||||
"journal_max_size", "journal_max_backups", "default_owner",
|
"journal_max_size", "journal_max_backups", "default_owner",
|
||||||
|
"default_threshold_config",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Top-level keys managed by the 'dns' logical section
|
# Top-level keys managed by the 'dns' logical section
|
||||||
_DNS_KEYS = ["nsupdate_bin", "dyndomains", "dyndnshosts", "drophosts"]
|
_DNS_KEYS = ["nsupdate_bin", "rndc_key", "dyndomains"]
|
||||||
|
|
||||||
|
|
||||||
def read_roundtrip(path: str):
|
def read_roundtrip(path: str):
|
||||||
@@ -89,10 +90,25 @@ def apply_structured_section(data, section: str, values: dict) -> None:
|
|||||||
data[key] = values[key]
|
data[key] = values[key]
|
||||||
elif section == "users":
|
elif section == "users":
|
||||||
data["users"] = values
|
data["users"] = values
|
||||||
|
elif section == "hosts":
|
||||||
|
data["hosts"] = values
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown structured section: {section!r}")
|
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:
|
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
|
||||||
"""Replace the named logical section by parsing yaml_text."""
|
"""Replace the named logical section by parsing yaml_text."""
|
||||||
parsed = _make_yaml().load(yaml_text)
|
parsed = _make_yaml().load(yaml_text)
|
||||||
|
|||||||
+18
-15
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
from subprocess import Popen, PIPE, STDOUT
|
from subprocess import Popen, PIPE, STDOUT
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_nsupdate_payload(
|
def create_nsupdate_payload(
|
||||||
@@ -123,7 +126,6 @@ async def dns_update_worker(
|
|||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = f"changed address to {addr}"
|
|
||||||
for dyndomain in cfg.get("dyndomains", []):
|
for dyndomain in cfg.get("dyndomains", []):
|
||||||
err = await loop.run_in_executor(
|
err = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -135,28 +137,29 @@ async def dns_update_worker(
|
|||||||
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
m += f", DNS update failed: {err}"
|
m = f"DNS update failed for {addr} ({dyndomain}): {err}"
|
||||||
logger.error("DNS update failed for %s: %s", name, err)
|
logger.error("DNS update failed for %s: %s", name, err)
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "ERROR", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
m += ", DNS updated."
|
m = f"DNS updated {name}.dy.{dyndomain} → {addr}"
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "INFO", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not cfg.get("dyndomains"):
|
||||||
|
logger.warning("DNS update triggered for %s but no dyndomains configured", name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dnsq.task_done()
|
dnsq.task_done()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, name, m)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def start_dns_worker(
|
def start_dns_worker(
|
||||||
hbdclass,
|
hbdclass,
|
||||||
|
|||||||
+411
-3
@@ -25,6 +25,74 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
|
||||||
|
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
||||||
|
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
|
||||||
|
|
||||||
|
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
|
||||||
|
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for config_name, metrics in form_data.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
thresholds = {}
|
||||||
|
for metric_path, values in metrics.items():
|
||||||
|
_insert_threshold_metric(thresholds, metric_path, values)
|
||||||
|
result[config_name] = {"thresholds": thresholds}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -> None:
|
||||||
|
"""Insert a single metric into the nested threshold YAML structure."""
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = {}
|
||||||
|
op = values.get("operator", ">")
|
||||||
|
if op and op != ">":
|
||||||
|
cfg["operator"] = op
|
||||||
|
|
||||||
|
for key, cast in (("warning", float), ("critical", float), ("hysteresis", float)):
|
||||||
|
v = values.get(key)
|
||||||
|
if v is not None:
|
||||||
|
try:
|
||||||
|
cfg[key] = cast(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
count = values.get("count")
|
||||||
|
if count is not None:
|
||||||
|
try:
|
||||||
|
cfg["count"] = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
display = values.get("display", "")
|
||||||
|
if display:
|
||||||
|
cfg["display"] = display
|
||||||
|
|
||||||
|
if not values.get("enabled", True):
|
||||||
|
cfg["enabled"] = False
|
||||||
|
|
||||||
|
parts = metric_path.split(".", 2)
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
# e.g. "rtt"
|
||||||
|
thresholds[metric_path] = cfg
|
||||||
|
elif len(parts) == 2:
|
||||||
|
plugin, metric = parts
|
||||||
|
thresholds.setdefault(plugin, {})[metric] = cfg
|
||||||
|
else:
|
||||||
|
plugin, intermediate, leaf = parts
|
||||||
|
thresholds.setdefault(plugin, {})
|
||||||
|
if plugin == "disk_monitor":
|
||||||
|
thresholds[plugin].setdefault("partitions", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
elif plugin == "zfs_monitor":
|
||||||
|
thresholds[plugin].setdefault("pools", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
else:
|
||||||
|
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
|
||||||
def _render_template(html_str: str, **context) -> str:
|
def _render_template(html_str: str, **context) -> str:
|
||||||
tmpl = jinja2.Template(html_str)
|
tmpl = jinja2.Template(html_str)
|
||||||
return tmpl.render(**context)
|
return tmpl.render(**context)
|
||||||
@@ -126,6 +194,66 @@ def _mask_config_for_api(config) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
|
hbc_version = None
|
||||||
|
hbc_type = None
|
||||||
|
latest_os = host.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
_, os_data = latest_os
|
||||||
|
hbc_version = os_data.get("hbc_version")
|
||||||
|
hbc_type = os_data.get("hbc_type")
|
||||||
|
|
||||||
|
last_packet = None
|
||||||
|
if host.connections:
|
||||||
|
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||||
|
|
||||||
|
thresholds = None
|
||||||
|
if threshold_checker is not None:
|
||||||
|
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||||
|
|
||||||
|
# Build reverse coverage: which metric paths suffix-match to each threshold.
|
||||||
|
# Mirrors the logic in ThresholdChecker._find_threshold.
|
||||||
|
coverage: dict = {}
|
||||||
|
for plugin_name, samples in host.plugin_data.items():
|
||||||
|
if not samples:
|
||||||
|
continue
|
||||||
|
_, pdata = samples[-1]
|
||||||
|
for field_name in pdata:
|
||||||
|
full_path = f"{plugin_name}.{field_name}"
|
||||||
|
if full_path in raw:
|
||||||
|
continue # exact match — the threshold IS this metric
|
||||||
|
parts = field_name.split("_")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
candidate = f"{plugin_name}." + "_".join(parts[i:])
|
||||||
|
if candidate in raw:
|
||||||
|
coverage.setdefault(candidate, []).append(full_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
thresholds = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
"covers": sorted(coverage.get(tc.metric_path, [])),
|
||||||
|
}
|
||||||
|
for tc in raw.values()
|
||||||
|
],
|
||||||
|
key=lambda x: x["metric"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": getattr(host, "owner", None),
|
||||||
|
"managers": list(getattr(host, "managers", [])),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
|
"hbc_type": hbc_type,
|
||||||
|
"last_packet": last_packet,
|
||||||
|
"thresholds": thresholds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -828,6 +956,23 @@ async def start(
|
|||||||
|
|
||||||
return web.json_response(host.access_dict())
|
return web.json_response(host.access_dict())
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Host info endpoint
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def api_host_info(request):
|
||||||
|
"""GET /api/0/hosts/{hostname}/info"""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
hostname = request.match_info.get("hostname")
|
||||||
|
if hostname not in hbdclass.Host.hosts:
|
||||||
|
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||||
|
host = hbdclass.Host.hosts[hostname]
|
||||||
|
if not _can_view_host(user, host):
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# User profile page
|
# User profile page
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -879,7 +1024,23 @@ async def start(
|
|||||||
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
||||||
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
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")
|
tmpl = env.get_template("profile.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
@@ -890,6 +1051,7 @@ async def start(
|
|||||||
managed_hosts=managed,
|
managed_hosts=managed,
|
||||||
monitored_hosts=monitored,
|
monitored_hosts=monitored,
|
||||||
notification_channels=notif_channels,
|
notification_channels=notif_channels,
|
||||||
|
all_channels=all_channels,
|
||||||
all_channel_names=all_channel_names,
|
all_channel_names=all_channel_names,
|
||||||
active_page="profile",
|
active_page="profile",
|
||||||
)
|
)
|
||||||
@@ -955,6 +1117,8 @@ async def start(
|
|||||||
title="Settings - Heartbeat",
|
title="Settings - Heartbeat",
|
||||||
sections=settings_data["sections"],
|
sections=settings_data["sections"],
|
||||||
all_channel_names=settings_data["all_channel_names"],
|
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,
|
current_user=current_user.to_dict() if current_user else None,
|
||||||
active_page="settings",
|
active_page="settings",
|
||||||
)
|
)
|
||||||
@@ -1132,10 +1296,24 @@ async def start(
|
|||||||
attrs.pop("client_secret", None)
|
attrs.pop("client_secret", None)
|
||||||
data["oauth"] = new_oauth
|
data["oauth"] = new_oauth
|
||||||
|
|
||||||
for section in ("notification_channels", "thresholds", "hosts", "dns"):
|
for section in ("notification_channels", "dns"):
|
||||||
if section in payload:
|
if section in payload:
|
||||||
configio_mod.apply_yaml_section(data, section, payload[section])
|
configio_mod.apply_yaml_section(data, section, payload[section])
|
||||||
|
|
||||||
|
if "thresholds" in payload:
|
||||||
|
tc = payload["thresholds"]
|
||||||
|
if isinstance(tc, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
|
elif isinstance(tc, dict):
|
||||||
|
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
||||||
|
|
||||||
|
if "hosts" in payload:
|
||||||
|
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)
|
configio_mod.write_config(_config_path, data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Config write failed: %s", exc)
|
logger.error("Config write failed: %s", exc)
|
||||||
@@ -1178,6 +1356,226 @@ async def start(
|
|||||||
|
|
||||||
return web.json_response({"ok": True})
|
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 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 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 hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
async def api_user_self_put(request):
|
async def api_user_self_put(request):
|
||||||
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
|
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
@@ -1220,7 +1618,10 @@ async def start(
|
|||||||
if "avatar" in body:
|
if "avatar" in body:
|
||||||
user_entry["avatar"] = str(body["avatar"])
|
user_entry["avatar"] = str(body["avatar"])
|
||||||
if "notification_channels" in body:
|
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:
|
if password_change:
|
||||||
user_entry["password"] = users_mod.hash_password(password_change["new"])
|
user_entry["password"] = users_mod.hash_password(password_change["new"])
|
||||||
|
|
||||||
@@ -1260,6 +1661,12 @@ async def start(
|
|||||||
web.get("/api/0/config/backups", api_config_backups_get),
|
web.get("/api/0/config/backups", api_config_backups_get),
|
||||||
web.post("/api/0/config", api_config_post),
|
web.post("/api/0/config", api_config_post),
|
||||||
web.post("/api/0/config/rollback", api_config_rollback),
|
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
|
# Hosts
|
||||||
web.get("/api/0/hosts", api_hosts),
|
web.get("/api/0/hosts", api_hosts),
|
||||||
web.get("/api/0/alert_summary", api_alert_summary),
|
web.get("/api/0/alert_summary", api_alert_summary),
|
||||||
@@ -1269,6 +1676,7 @@ async def start(
|
|||||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||||
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
||||||
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
||||||
|
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||||
web.get("/api/0/alerts", api_all_alerts),
|
web.get("/api/0/alerts", api_all_alerts),
|
||||||
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
||||||
web.get("/c", cmd),
|
web.get("/c", cmd),
|
||||||
|
|||||||
+1
-9
@@ -78,9 +78,7 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
True if reload succeeded, False otherwise
|
True if reload succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Starting configuration reload...")
|
logger.info("Starting configuration reload...")
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
# Reload config file
|
# Reload config file
|
||||||
new_config = await config_obj.reload(config_path)
|
new_config = await config_obj.reload(config_path)
|
||||||
@@ -115,13 +113,11 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
# These are reloadable and effective immediately:
|
# These are reloadable and effective immediately:
|
||||||
# - notification_channels
|
# - notification_channels
|
||||||
# - threshold_configs
|
# - threshold_configs
|
||||||
# - hosts (watchhosts, dyndnshosts, notification_channels)
|
# - hosts (watchhosts, dyndns, notification_channels)
|
||||||
# - grace period (used on next heartbeat)
|
# - grace period (used on next heartbeat)
|
||||||
# - debug/verbose flags (used on next message)
|
# - debug/verbose flags (used on next message)
|
||||||
|
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Configuration reload completed successfully")
|
logger.info("Configuration reload completed successfully")
|
||||||
logger.info("=" * 60)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -422,7 +418,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
pickfile = config.get("pickfile", "hbd.pickle")
|
pickfile = config.get("pickfile", "hbd.pickle")
|
||||||
dyndnshosts = config_mod.get_dyndnshosts(config)
|
dyndnshosts = config_mod.get_dyndnshosts(config)
|
||||||
watchhosts = config_mod.get_watchhosts(config)
|
watchhosts = config_mod.get_watchhosts(config)
|
||||||
drophosts = config.get("drophosts", [])
|
|
||||||
if 1 and os.path.exists(pickfile):
|
if 1 and os.path.exists(pickfile):
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("opening pickls %s", pickfile)
|
logger.info("opening pickls %s", pickfile)
|
||||||
@@ -448,9 +443,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
hbdclass.Host.hosts[h].apply_access(
|
hbdclass.Host.hosts[h].apply_access(
|
||||||
access["owner"], access["managers"], access["monitors"]
|
access["owner"], access["managers"], access["monitors"]
|
||||||
)
|
)
|
||||||
for h in drophosts:
|
|
||||||
if h in hbdclass.Host.hosts:
|
|
||||||
del hbdclass.Host.hosts[h]
|
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send
|
|||||||
|
|
||||||
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||||
"""Send *notif* to a single named channel, honouring min_level."""
|
"""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()
|
level = notif.level.upper()
|
||||||
if level != "RECOVER":
|
if level != "RECOVER":
|
||||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||||
|
|||||||
+79
-12
@@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({
|
|||||||
"smtp_password", "smtp_user", "api_password", "access_token",
|
"smtp_password", "smtp_user", "api_password", "access_token",
|
||||||
})
|
})
|
||||||
|
|
||||||
_CHANNEL_TYPE_LABELS = {
|
CHANNEL_TYPE_SCHEMAS = {
|
||||||
"pushover": "Pushover",
|
"pushover": {
|
||||||
"email": "E-mail",
|
"label": "Pushover",
|
||||||
"signal": "Signal",
|
"fields": [
|
||||||
"mattermost": "Mattermost",
|
{"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):
|
def _mask(value):
|
||||||
"""Return a masked placeholder for sensitive values."""
|
"""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) ----------------
|
# ---- Notification channels (complex, built separately) ----------------
|
||||||
|
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
|
||||||
notif_channels = []
|
notif_channels = []
|
||||||
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
|
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
|
||||||
if not isinstance(ch_cfg, dict):
|
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", "")
|
ch_type = ch_cfg.get("type", "")
|
||||||
fields = []
|
fields = []
|
||||||
for k, v in ch_cfg.items():
|
for k, v in ch_cfg.items():
|
||||||
if k == "type":
|
if k in _METADATA_KEYS:
|
||||||
continue
|
continue
|
||||||
sensitive = k in _SECRET_KEYS
|
sensitive = k in _SECRET_KEYS
|
||||||
fields.append({
|
fields.append({
|
||||||
@@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"name": ch_name,
|
"name": ch_name,
|
||||||
"type": ch_type,
|
"type": ch_type,
|
||||||
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
"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,
|
"fields": fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,6 +247,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"hysteresis": tc.hysteresis,
|
"hysteresis": tc.hysteresis,
|
||||||
"count": tc.count,
|
"count": tc.count,
|
||||||
"enabled": tc.enabled,
|
"enabled": tc.enabled,
|
||||||
|
"display": tc.display or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold_config_list = []
|
threshold_config_list = []
|
||||||
@@ -228,7 +285,10 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"owner": hcfg.get("owner", ""),
|
"owner": hcfg.get("owner", ""),
|
||||||
"managers": hcfg.get("managers", []),
|
"managers": hcfg.get("managers", []),
|
||||||
"monitors": hcfg.get("monitors", []),
|
"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", []),
|
"notification_channels": hcfg.get("notification_channels", []),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,7 +428,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "channels",
|
"id": "channels",
|
||||||
"title": "Notification Channels",
|
"title": "Notification Channels",
|
||||||
"description": "Named notification providers. Credentials are masked.",
|
"description": "Named notification providers. Credentials are masked.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "channels",
|
||||||
"api_section": "notification_channels",
|
"api_section": "notification_channels",
|
||||||
"channels": notif_channels,
|
"channels": notif_channels,
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -380,7 +440,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "hosts",
|
"id": "hosts",
|
||||||
"title": "Hosts",
|
"title": "Hosts",
|
||||||
"description": "Host definitions loaded from the config file.",
|
"description": "Host definitions loaded from the config file.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "hosts",
|
||||||
"api_section": "hosts",
|
"api_section": "hosts",
|
||||||
"hosts": hosts_list,
|
"hosts": hosts_list,
|
||||||
"fields": [],
|
"fields": [],
|
||||||
@@ -389,12 +449,12 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "thresholds",
|
"id": "thresholds",
|
||||||
"title": "Threshold Configurations",
|
"title": "Threshold Configurations",
|
||||||
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "thresholds",
|
||||||
"api_section": "thresholds",
|
"api_section": "thresholds",
|
||||||
"threshold_configs": threshold_config_list,
|
"threshold_configs": threshold_config_list,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_threshold_config", "Default config", "text",
|
field("default_threshold_config", "Default config", "text",
|
||||||
"Threshold config used for hosts with no explicit mapping."),
|
"Threshold config used for hosts with no explicit mapping.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -419,4 +479,11 @@ def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
|||||||
"""Return sections list + auxiliary data for the settings template."""
|
"""Return sections list + auxiliary data for the settings template."""
|
||||||
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
||||||
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,23 @@
|
|||||||
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pending config publish button */
|
||||||
|
.nav-publish-btn {
|
||||||
|
background: #e65100;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.nav-publish-btn:hover { background: #bf360c; }
|
||||||
|
.nav-publish-btn:disabled { opacity: 0.7; cursor: default; }
|
||||||
|
|
||||||
/* Swiss railway clock — nav */
|
/* Swiss railway clock — nav */
|
||||||
.nav-pie {
|
.nav-pie {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -201,6 +201,43 @@
|
|||||||
.log-recover .log-level { color: #2a7a2a; }
|
.log-recover .log-level { color: #2a7a2a; }
|
||||||
.log-info .log-level { color: #555; }
|
.log-info .log-level { color: #555; }
|
||||||
|
|
||||||
|
.log-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-section-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"],
|
||||||
|
.log-filter-bar select {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"] { width: 110px; }
|
||||||
|
|
||||||
/* Modal for connection status messages */
|
/* Modal for connection status messages */
|
||||||
.connection-modal {
|
.connection-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -445,6 +482,22 @@
|
|||||||
updateRowAlert(name_idx[data.name], data);
|
updateRowAlert(name_idx[data.name], data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLogFilters() {
|
||||||
|
var hostFilter = document.getElementById('filter-host').value.toLowerCase().trim();
|
||||||
|
var levelFilter = document.getElementById('filter-level').value;
|
||||||
|
var msgFilter = document.getElementById('filter-msg').value.toLowerCase().trim();
|
||||||
|
document.querySelectorAll('#messages .log-entry').forEach(function(entry) {
|
||||||
|
var show = true;
|
||||||
|
if (hostFilter && !(entry.dataset.host || '').toLowerCase().includes(hostFilter)) show = false;
|
||||||
|
if (levelFilter && entry.dataset.level !== levelFilter) show = false;
|
||||||
|
if (msgFilter) {
|
||||||
|
var msgEl = entry.querySelector('.log-msg');
|
||||||
|
if (!msgEl || !msgEl.textContent.toLowerCase().includes(msgFilter)) show = false;
|
||||||
|
}
|
||||||
|
entry.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function WS_Connect() {
|
function WS_Connect() {
|
||||||
if ("WebSocket" in window) {
|
if ("WebSocket" in window) {
|
||||||
//N.B: subprotocol field causes chrome to error 1006
|
//N.B: subprotocol field causes chrome to error 1006
|
||||||
@@ -479,7 +532,8 @@
|
|||||||
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
||||||
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||||
var lvl = (msg.level || "INFO").toLowerCase();
|
var lvl = (msg.level || "INFO").toLowerCase();
|
||||||
var html = '<div class="log-entry log-' + lvl + '">';
|
var hostVal = msg.host || '';
|
||||||
|
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '"') + '">';
|
||||||
html += '<span class="log-ts">' + ts_str + '</span>';
|
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||||
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
||||||
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
||||||
@@ -487,6 +541,7 @@
|
|||||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
msgs.insertAdjacentHTML("afterbegin", html);
|
msgs.insertAdjacentHTML("afterbegin", html);
|
||||||
|
applyLogFilters();
|
||||||
}
|
}
|
||||||
cnt++;
|
cnt++;
|
||||||
};
|
};
|
||||||
@@ -575,7 +630,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-section">
|
<div class="log-section">
|
||||||
<h2>Log of Events</h2>
|
<div class="log-section-header">
|
||||||
|
<span class="log-section-title">Log of Events</span>
|
||||||
|
<div class="log-filter-bar">
|
||||||
|
<input type="text" id="filter-host" placeholder="Host…" title="Filter by host" />
|
||||||
|
<select id="filter-level" title="Filter by level">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="info">INFO</option>
|
||||||
|
<option value="warning">WARNING</option>
|
||||||
|
<option value="critical">CRITICAL</option>
|
||||||
|
<option value="recover">RECOVER</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,6 +659,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
setup();
|
setup();
|
||||||
|
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
|
||||||
|
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
|
||||||
|
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if current_user and current_user.admin %}
|
||||||
|
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">⚠ Publish Config</button>
|
||||||
|
{% endif %}
|
||||||
<div class="nav-pie" title="Host alert status">
|
<div class="nav-pie" title="Host alert status">
|
||||||
<canvas id="alert-pie" width="44" height="44"></canvas>
|
<canvas id="alert-pie" width="44" height="44"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,5 +95,40 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateAlertPie();
|
updateAlertPie();
|
||||||
setInterval(updateAlertPie, 30000);
|
setInterval(updateAlertPie, 30000);
|
||||||
|
navCheckPendingConfig();
|
||||||
|
window.addEventListener('storage', navCheckPendingConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function navCheckPendingConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.style.display = localStorage.getItem('hbd_pending_config') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navPublishConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
var pending = localStorage.getItem('hbd_pending_config');
|
||||||
|
if (!pending) return;
|
||||||
|
var staged;
|
||||||
|
try { staged = JSON.parse(pending); } catch(e) { return; }
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/0/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: pending
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
var err = await resp.json().catch(function() { return {}; });
|
||||||
|
alert('Error: ' + (err.error || resp.statusText));
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Network error: ' + e.message);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -388,6 +388,30 @@
|
|||||||
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
||||||
|
|
||||||
|
/* ── Host info section ──────────────────────────────────────────────────── */
|
||||||
|
.host-info-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.info-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 3px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
||||||
|
.info-value { color: #222; }
|
||||||
|
.info-thresholds-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.info-note { color: #888; font-style: italic; }
|
||||||
|
.info-loading { color: #bbb; font-style: italic; }
|
||||||
|
.threshold-covers { font-size: 0.85em; color: #777; font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -436,6 +460,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-body">
|
<div class="host-body">
|
||||||
|
<div class="host-info-section" id="info-{{ host.name }}">
|
||||||
|
<div class="info-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
||||||
{% for plugin in plugin_order if plugin in host.plugins %}
|
{% for plugin in plugin_order if plugin in host.plugins %}
|
||||||
<div class="plugin-accordion collapsed"
|
<div class="plugin-accordion collapsed"
|
||||||
@@ -488,6 +515,9 @@
|
|||||||
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
||||||
const pluginCache = {};
|
const pluginCache = {};
|
||||||
|
|
||||||
|
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
||||||
|
const infoCache = {};
|
||||||
|
|
||||||
function setCache(hostname, pluginName, sample) {
|
function setCache(hostname, pluginName, sample) {
|
||||||
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
||||||
pluginCache[hostname][pluginName] = {
|
pluginCache[hostname][pluginName] = {
|
||||||
@@ -521,6 +551,61 @@
|
|||||||
return json.samples?.[0] ?? null;
|
return json.samples?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchHostInfo(hostname) {
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInfoSection(hostname, data) {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const owner = data.owner ? escHtml(data.owner) : '—';
|
||||||
|
const managers = data.managers && data.managers.length
|
||||||
|
? data.managers.map(escHtml).join(', ') : '—';
|
||||||
|
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
||||||
|
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
||||||
|
const lastPkt = data.last_packet != null
|
||||||
|
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
||||||
|
|
||||||
|
let html = `<div class="info-meta">
|
||||||
|
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
||||||
|
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
||||||
|
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
||||||
|
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
||||||
|
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (data.thresholds === null) {
|
||||||
|
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
||||||
|
} else if (data.thresholds.length === 0) {
|
||||||
|
html += `<div class="info-note">No thresholds defined.</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
for (const t of data.thresholds) {
|
||||||
|
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
|
||||||
|
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
|
||||||
|
let metricCell = escHtml(t.metric);
|
||||||
|
if (t.covers && t.covers.length > 0) {
|
||||||
|
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
|
||||||
|
}
|
||||||
|
html += `<tr>
|
||||||
|
<td class="key">${metricCell}</td>
|
||||||
|
<td>${escHtml(t.operator)}</td>
|
||||||
|
<td>${w}</td>
|
||||||
|
<td>${c}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHostGlance(hostname) {
|
async function fetchHostGlance(hostname) {
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
||||||
@@ -644,8 +729,21 @@
|
|||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const wasCollapsed = card.classList.contains('collapsed');
|
const wasCollapsed = card.classList.contains('collapsed');
|
||||||
card.classList.toggle('collapsed');
|
card.classList.toggle('collapsed');
|
||||||
if (wasCollapsed && !pluginCache[hostname]) {
|
if (wasCollapsed) {
|
||||||
fetchHostGlance(hostname);
|
if (!pluginCache[hostname]) {
|
||||||
|
fetchHostGlance(hostname);
|
||||||
|
}
|
||||||
|
if (!infoCache[hostname]) {
|
||||||
|
const infoEl = document.getElementById(`info-${hostname}`);
|
||||||
|
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,10 +892,11 @@
|
|||||||
function renderOsInfoTable(d) {
|
function renderOsInfoTable(d) {
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
'processor','architecture','node','python_version',
|
'processor','architecture','node','python_version',
|
||||||
'python_implementation','hbc_version',
|
'python_implementation',
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||||
const shown = new Set(ORDER);
|
const shown = new Set(ORDER);
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||||
|
|
||||||
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
@@ -1206,9 +1305,12 @@
|
|||||||
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
|
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
document.querySelectorAll('.host-card').forEach(card => {
|
||||||
|
fetchHostGlance(card.dataset.hostname);
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
||||||
const hostname = card.dataset.hostname;
|
const hostname = card.dataset.hostname;
|
||||||
fetchHostGlance(hostname);
|
|
||||||
|
|
||||||
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
||||||
const pname = acc.dataset.plugin;
|
const pname = acc.dataset.plugin;
|
||||||
@@ -1228,24 +1330,39 @@
|
|||||||
// ── Init ────────────────────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// If a host fragment is in the URL, expand and scroll to that host;
|
// Fetch glance data for every host immediately so the strip is always populated.
|
||||||
// otherwise expand the first host as before.
|
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;
|
const hash = window.location.hash;
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const hostname = decodeURIComponent(hash.slice(1));
|
const hostname = decodeURIComponent(hash.slice(1));
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
if (expandHost(hostname)) {
|
||||||
if (card) {
|
setTimeout(() => {
|
||||||
card.classList.remove('collapsed');
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
fetchHostGlance(hostname);
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
setTimeout(() => card.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
|
}, 150);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const first = document.querySelector('.host-card');
|
const first = document.querySelector('.host-card');
|
||||||
if (first) {
|
if (first) expandHost(first.dataset.hostname);
|
||||||
first.classList.remove('collapsed');
|
|
||||||
fetchHostGlance(first.dataset.hostname);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// ── Host action helpers ──────────────────────────────────────
|
// ── Host action helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -215,11 +215,59 @@
|
|||||||
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
.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 { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
||||||
.btn-save:hover { background: #0055aa; }
|
.btn-save:hover { background: #0055aa; }
|
||||||
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
|
/* ---- Channel chip picker ---- */
|
||||||
.channel-item:last-child { border-bottom: none; }
|
.ch-picker { }
|
||||||
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
|
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
|
||||||
.channel-item .ch-name { font-weight: 500; color: #222; }
|
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
|
||||||
.channel-item .ch-meta { font-size: .8em; color: #888; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -318,37 +366,117 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Notification channels -->
|
<!-- Notification channels — chip picker -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Notification Channels</h2>
|
<h2>Notification Channels</h2>
|
||||||
{% if current_user %}
|
{% 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>
|
<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_channel_names %}
|
{% if all_channels %}
|
||||||
<div id="channel-checkboxes">
|
<div class="ch-picker">
|
||||||
{% for ch_name in all_channel_names %}
|
<div class="ch-picker-label">Selected</div>
|
||||||
<div class="channel-item">
|
<div id="selected-chips" class="ch-chips">
|
||||||
<label>
|
{% for ch in all_channels %}
|
||||||
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
|
{% if ch.name in (current_user.notification_channels or []) %}
|
||||||
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
|
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||||||
<div>
|
{{ ch.name | e }} <span class="ch-chip-x">×</span>
|
||||||
<div class="ch-name">{{ ch_name | e }}</div>
|
</button>
|
||||||
</div>
|
{% endif %}
|
||||||
</label>
|
{% 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>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
<div class="save-row" style="margin-top:10px">
|
<div class="save-row">
|
||||||
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
||||||
<span id="channels-status" class="status-msg"></span>
|
<span id="channels-status" class="status-msg"></span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="no-hosts">No personal notification channels configured.</span>
|
<span class="no-hosts">Log in to manage notification channels.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- Host access -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Host Access</h2>
|
<h2>Host Access</h2>
|
||||||
@@ -395,6 +523,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// ---- Identity ----
|
||||||
async function saveIdentity() {
|
async function saveIdentity() {
|
||||||
const full_name = document.getElementById('profile-fullname').value;
|
const full_name = document.getElementById('profile-fullname').value;
|
||||||
const avatar = document.getElementById('profile-avatar').value;
|
const avatar = document.getElementById('profile-avatar').value;
|
||||||
@@ -411,6 +540,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Password ----
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
const current = document.getElementById('profile-current-pw').value;
|
const current = document.getElementById('profile-current-pw').value;
|
||||||
const newpw = document.getElementById('profile-new-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() {
|
async function saveChannels() {
|
||||||
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
|
const notification_channels = [
|
||||||
.map(cb => cb.value);
|
...document.querySelectorAll('#selected-chips .ch-chip.selected')
|
||||||
|
].map(b => b.dataset.ch);
|
||||||
const resp = await fetch('/api/0/users/me', {
|
const resp = await fetch('/api/0/users/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
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) {
|
function showStatus(id, msg, color) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -456,6 +746,12 @@
|
|||||||
el.style.color = color;
|
el.style.color = color;
|
||||||
setTimeout(() => { el.textContent = ''; }, 3000);
|
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
html, body { overflow: visible; }
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||||
@@ -207,6 +207,36 @@
|
|||||||
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
||||||
.channel-field-value { color: #333; word-break: break-all; }
|
.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 ---- */
|
/* ---- Hosts table ---- */
|
||||||
/* ---- Mobile: collapsible sidebar ---- */
|
/* ---- Mobile: collapsible sidebar ---- */
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
@@ -268,8 +298,6 @@
|
|||||||
|
|
||||||
/* ---- Editable inputs ---- */
|
/* ---- Editable inputs ---- */
|
||||||
.field-input {
|
.field-input {
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -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 th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
||||||
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
||||||
.crud-table tbody tr:last-child td { border-bottom: none; }
|
.crud-table tbody tr:last-child td { border-bottom: none; }
|
||||||
.crud-table .field-input { max-width: none; }
|
.crud-table .field-input { width: 100%; }
|
||||||
|
|
||||||
/* ---- Rollback modal ---- */
|
/* ---- Rollback modal ---- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
@@ -352,9 +380,88 @@
|
|||||||
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
||||||
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
||||||
.backup-row:last-child { border-bottom: none; }
|
.backup-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ---- Threshold config cards ---- */
|
||||||
|
.thresh-cfg-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thresh-cfg-header {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.thresh-cfg-name-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
.thresh-metric-table { width: 100%; }
|
||||||
|
.thresh-metric-table th { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- Multi-picker ---- */
|
||||||
|
.mpick-wrapper { display: block; }
|
||||||
|
.mpick-display {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
|
||||||
|
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
|
||||||
|
background: #fff; user-select: none; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
|
||||||
|
.mpick-tag {
|
||||||
|
padding: 1px 6px; background: #e8eaf6; color: #283593;
|
||||||
|
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
|
||||||
|
.mpick-panel {
|
||||||
|
position: fixed; background: #fff; border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
|
||||||
|
z-index: 2000; width: 360px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-panel-header {
|
||||||
|
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
|
||||||
|
border-bottom: 1px solid #eee; display: flex;
|
||||||
|
justify-content: space-between; align-items: center; background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.mpick-panel-body { display: flex; }
|
||||||
|
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
|
||||||
|
.mpick-col-header {
|
||||||
|
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
|
||||||
|
border-bottom: 1px solid #f0f0f0; background: #fafafa;
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||||||
|
.mpick-item {
|
||||||
|
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||||||
|
}
|
||||||
|
.mpick-item:last-child { border-bottom: none; }
|
||||||
|
.mpick-item-avail:hover { background: #e8f5e9; }
|
||||||
|
.mpick-item-sel:hover { background: #fce4ec; }
|
||||||
|
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
|
||||||
|
.mpick-item:hover .mpick-arrow { opacity: 1; }
|
||||||
|
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
|
||||||
|
.mpick-item-sel .mpick-arrow { color: #c62828; }
|
||||||
|
.mpick-panel-footer {
|
||||||
|
padding: 6px 10px; border-top: 1px solid #eee;
|
||||||
|
display: flex; justify-content: flex-end; background: #f8f8f8;
|
||||||
|
}
|
||||||
|
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
{%- macro mpick(all_items, sel, cls) -%}
|
||||||
|
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
|
||||||
|
{%- endmacro %}
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -381,6 +488,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="settings-layout">
|
||||||
|
|
||||||
<!-- Sidebar navigation -->
|
<!-- 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-full-name" value="{{ u.full_name | e }}"></td>
|
||||||
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
||||||
<td style="min-width:120px">
|
<td style="min-width:140px">
|
||||||
{% for ch in all_channel_names %}
|
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
|
||||||
<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>
|
</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
||||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
@@ -488,6 +627,167 @@
|
|||||||
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||||||
</div>
|
</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 ---- #}
|
{# ---- YAML editor section ---- #}
|
||||||
{% elif section.section_mode == 'yaml' %}
|
{% elif section.section_mode == 'yaml' %}
|
||||||
<div style="padding: 12px 20px">
|
<div style="padding: 12px 20px">
|
||||||
@@ -554,8 +854,140 @@
|
|||||||
</div>{# /container #}
|
</div>{# /container #}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---- Channel names for add-user row ----
|
// ---- Lookup arrays for CRUD rows ----
|
||||||
const _allChannels = {{ all_channel_names | tojson }};
|
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 ----
|
// ---- Staged changes accumulator ----
|
||||||
const _staged = {};
|
const _staged = {};
|
||||||
@@ -566,9 +998,13 @@
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
document.getElementById('pending-count').textContent = count;
|
document.getElementById('pending-count').textContent = count;
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
|
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
|
||||||
} else {
|
} else {
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
}
|
}
|
||||||
|
const navBtn = document.getElementById('nav-publish-btn');
|
||||||
|
if (navBtn) navBtn.style.display = count > 0 ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageFormSection(sectionId, apiSection) {
|
function stageFormSection(sectionId, apiSection) {
|
||||||
@@ -605,7 +1041,7 @@
|
|||||||
full_name: row.querySelector('.user-full-name').value,
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
avatar: row.querySelector('.user-avatar').value,
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
admin: row.querySelector('.user-admin').checked,
|
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;
|
const pw = row.querySelector('.user-password').value;
|
||||||
if (pw) entry.password = pw;
|
if (pw) entry.password = pw;
|
||||||
@@ -619,7 +1055,7 @@
|
|||||||
full_name: row.querySelector('.user-full-name').value,
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
avatar: row.querySelector('.user-avatar').value,
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
admin: row.querySelector('.user-admin').checked,
|
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;
|
const pw = row.querySelector('.user-password').value;
|
||||||
if (pw) entry.password = pw;
|
if (pw) entry.password = pw;
|
||||||
@@ -663,6 +1099,57 @@
|
|||||||
flashStaged('oauth');
|
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() {
|
async function publishAll() {
|
||||||
const btn = document.querySelector('[onclick="publishAll()"]');
|
const btn = document.querySelector('[onclick="publishAll()"]');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -690,6 +1177,7 @@
|
|||||||
|
|
||||||
function discardAll() {
|
function discardAll() {
|
||||||
Object.keys(_staged).forEach(k => delete _staged[k]);
|
Object.keys(_staged).forEach(k => delete _staged[k]);
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
updatePendingBanner();
|
updatePendingBanner();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -707,6 +1195,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
_loadChannelSchemas();
|
||||||
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
||||||
const sectionId = ta.id.replace('yaml-', '');
|
const sectionId = ta.id.replace('yaml-', '');
|
||||||
const section = document.getElementById(sectionId);
|
const section = document.getElementById(sectionId);
|
||||||
@@ -731,9 +1220,6 @@
|
|||||||
|
|
||||||
function addUserRow() {
|
function addUserRow() {
|
||||||
const tbody = document.getElementById('users-tbody');
|
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');
|
const row = document.createElement('tr');
|
||||||
row.setAttribute('data-new-user', 'true');
|
row.setAttribute('data-new-user', 'true');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -741,7 +1227,7 @@
|
|||||||
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
||||||
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
||||||
<td>${chHtml}</td>
|
<td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
||||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -821,6 +1307,121 @@
|
|||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Multi-picker ----
|
||||||
|
let _mpickPanel = null;
|
||||||
|
let _mpickTarget = null;
|
||||||
|
|
||||||
|
function _initMpickPanel() {
|
||||||
|
if (_mpickPanel) return;
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'mpick-panel';
|
||||||
|
p.style.display = 'none';
|
||||||
|
p.innerHTML = `
|
||||||
|
<div class="mpick-panel-header">
|
||||||
|
<span>Select items</span>
|
||||||
|
<button style="background:none;border:none;cursor:pointer;color:#888;font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-body">
|
||||||
|
<div class="mpick-col" id="mpick-avail-col">
|
||||||
|
<div class="mpick-col-header">Available</div>
|
||||||
|
<div id="mpick-avail"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-col" id="mpick-sel-col">
|
||||||
|
<div class="mpick-col-header">Selected</div>
|
||||||
|
<div id="mpick-sel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-footer">
|
||||||
|
<button class="btn btn-primary" style="font-size:.82em;padding:4px 12px" onclick="closeMpick()">Done</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(p);
|
||||||
|
_mpickPanel = p;
|
||||||
|
document.addEventListener('mousedown', e => {
|
||||||
|
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
|
||||||
|
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMpick(displayEl) {
|
||||||
|
if (displayEl.closest('tr')?.dataset.deleted === 'true') return;
|
||||||
|
_initMpickPanel();
|
||||||
|
_mpickTarget = displayEl.closest('.mpick-wrapper');
|
||||||
|
_rerenderMpick();
|
||||||
|
_mpickPanel.style.display = 'block';
|
||||||
|
const rect = displayEl.getBoundingClientRect();
|
||||||
|
const pw = _mpickPanel.offsetWidth || 360;
|
||||||
|
const ph = _mpickPanel.offsetHeight || 280;
|
||||||
|
let top = rect.bottom + 4;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
|
||||||
|
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
|
||||||
|
_mpickPanel.style.top = top + 'px';
|
||||||
|
_mpickPanel.style.left = left + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rerenderMpick() {
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const allOpts = [...sel.options];
|
||||||
|
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
|
||||||
|
const avail = allOpts.filter(o => !selVals.has(o.value));
|
||||||
|
const chosen = allOpts.filter(o => selVals.has(o.value));
|
||||||
|
document.getElementById('mpick-avail').innerHTML = avail.length
|
||||||
|
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">All selected</div>';
|
||||||
|
document.getElementById('mpick-sel').innerHTML = chosen.length
|
||||||
|
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">−</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">None selected</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mpickToggle(itemEl, toSelected) {
|
||||||
|
const val = itemEl.dataset.val;
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const opt = [...sel.options].find(o => o.value === val);
|
||||||
|
if (opt) opt.selected = toSelected;
|
||||||
|
_updateMpickDisplay(_mpickTarget);
|
||||||
|
_rerenderMpick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMpickDisplay(wrapper) {
|
||||||
|
const sel = wrapper.querySelector('select');
|
||||||
|
const display = wrapper.querySelector('.mpick-display');
|
||||||
|
const selected = [...sel.selectedOptions].map(o => o.value);
|
||||||
|
if (!selected.length) {
|
||||||
|
display.innerHTML = '<span class="mpick-empty">(none)</span>';
|
||||||
|
display.title = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const MAX = 2;
|
||||||
|
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
display.innerHTML = html;
|
||||||
|
display.title = selected.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMpick() {
|
||||||
|
if (_mpickPanel) _mpickPanel.style.display = 'none';
|
||||||
|
_mpickTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMpickHTML(allItems, selectedItems, cls) {
|
||||||
|
const selSet = new Set(selectedItems);
|
||||||
|
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
|
||||||
|
const selected = allItems.filter(v => selSet.has(v));
|
||||||
|
const MAX = 2;
|
||||||
|
let dispHtml;
|
||||||
|
if (!selected.length) {
|
||||||
|
dispHtml = '<span class="mpick-empty">(none)</span>';
|
||||||
|
} else {
|
||||||
|
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
}
|
||||||
|
const title = escHtml(selected.join(', '));
|
||||||
|
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight sidebar link for the section currently in view
|
// Highlight sidebar link for the section currently in view
|
||||||
const sections = document.querySelectorAll('.section');
|
const sections = document.querySelectorAll('.section');
|
||||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||||
@@ -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() {
|
function closeSidebar() {
|
||||||
var sidebarNav = document.getElementById('sidebar-nav');
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
|||||||
+31
-2
@@ -492,7 +492,27 @@ class ThresholdChecker:
|
|||||||
raw_overrides: Dict[str, ThresholdConfig] = {}
|
raw_overrides: Dict[str, ThresholdConfig] = {}
|
||||||
thresholds_config = config_data["thresholds"]
|
thresholds_config = config_data["thresholds"]
|
||||||
for plugin_name, plugin_thresholds in thresholds_config.items():
|
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._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=raw_overrides)
|
||||||
self.threshold_raw_configs[config_name] = raw_overrides
|
self.threshold_raw_configs[config_name] = raw_overrides
|
||||||
|
|
||||||
@@ -570,7 +590,16 @@ class ThresholdChecker:
|
|||||||
if plugin_name == "rtt":
|
if plugin_name == "rtt":
|
||||||
self._parse_rtt_thresholds(thresholds, target_dict)
|
self._parse_rtt_thresholds(thresholds, target_dict)
|
||||||
return
|
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():
|
for metric_name, threshold_config in thresholds.items():
|
||||||
if not isinstance(threshold_config, dict):
|
if not isinstance(threshold_config, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.0"
|
version = "5.3.3"
|
||||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+40
-26
@@ -1264,6 +1264,8 @@ static void usage(const char *prog) {
|
|||||||
" -c FILE Config file (JSON)\n"
|
" -c FILE Config file (JSON)\n"
|
||||||
" -m MSG Send one-shot message\n"
|
" -m MSG Send one-shot message\n"
|
||||||
" -n NAME Override hostname\n"
|
" -n NAME Override hostname\n"
|
||||||
|
" -4 Use IPv4 only\n"
|
||||||
|
" -6 Use IPv6 only\n"
|
||||||
" -d Daemonize\n"
|
" -d Daemonize\n"
|
||||||
" -v Verbose (info)\n"
|
" -v Verbose (info)\n"
|
||||||
" -x Debug\n"
|
" -x Debug\n"
|
||||||
@@ -1276,9 +1278,10 @@ int main(int argc, char **argv) {
|
|||||||
const char *cfgpath = NULL;
|
const char *cfgpath = NULL;
|
||||||
const char *message = NULL;
|
const char *message = NULL;
|
||||||
const char *nameov = NULL;
|
const char *nameov = NULL;
|
||||||
|
int af_filter = 0;
|
||||||
|
|
||||||
int opt;
|
int opt;
|
||||||
while ((opt = getopt(argc, argv, "bc:m:n:dvxh")) != -1) {
|
while ((opt = getopt(argc, argv, "bc:m:n:dvxh46")) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 'b': do_boot = true; break;
|
case 'b': do_boot = true; break;
|
||||||
case 'c': cfgpath = optarg; break;
|
case 'c': cfgpath = optarg; break;
|
||||||
@@ -1287,6 +1290,8 @@ int main(int argc, char **argv) {
|
|||||||
case 'd': do_daemon = true; break;
|
case 'd': do_daemon = true; break;
|
||||||
case 'v': g_log_level = LL_INFO; break;
|
case 'v': g_log_level = LL_INFO; break;
|
||||||
case 'x': g_log_level = LL_DEBUG; 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;
|
case 'h': usage(argv[0]); return 0;
|
||||||
default: usage(argv[0]); return 1;
|
default: usage(argv[0]); return 1;
|
||||||
}
|
}
|
||||||
@@ -1313,37 +1318,46 @@ int main(int argc, char **argv) {
|
|||||||
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
int conn_id = 1;
|
|
||||||
for (int i = 0; i < nhost; i++) {
|
|
||||||
struct addrinfo hints = {0}, *res = NULL;
|
|
||||||
hints.ai_socktype = SOCK_DGRAM;
|
|
||||||
hints.ai_protocol = IPPROTO_UDP;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
|
|
||||||
conn_t *c = &g_conns[g_nconns];
|
|
||||||
memset(c, 0, sizeof(*c));
|
|
||||||
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
|
||||||
c->af = ai->ai_family; c->sockfd = -1;
|
|
||||||
snprintf(c->name, sizeof(c->name), "%s", iam);
|
|
||||||
void *addr = (ai->ai_family == AF_INET)
|
|
||||||
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
|
||||||
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
|
||||||
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
|
||||||
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
|
||||||
}
|
|
||||||
freeaddrinfo(res);
|
|
||||||
}
|
|
||||||
if (!g_nconns) { LOGE("no connections established"); return 1; }
|
|
||||||
|
|
||||||
struct sigaction sa = {0};
|
struct sigaction sa = {0};
|
||||||
sa.sa_handler = sig_handler;
|
sa.sa_handler = sig_handler;
|
||||||
sigaction(SIGTERM, &sa, NULL);
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
sigaction(SIGINT, &sa, NULL);
|
sigaction(SIGINT, &sa, NULL);
|
||||||
sigaction(SIGHUP, &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) {
|
||||||
|
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];
|
||||||
|
memset(c, 0, sizeof(*c));
|
||||||
|
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
||||||
|
c->af = ai->ai_family; c->sockfd = -1;
|
||||||
|
snprintf(c->name, sizeof(c->name), "%s", iam);
|
||||||
|
void *addr = (ai->ai_family == AF_INET)
|
||||||
|
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
||||||
|
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
||||||
|
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
||||||
|
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
||||||
|
}
|
||||||
|
freeaddrinfo(res);
|
||||||
|
}
|
||||||
|
if (!g_nconns) {
|
||||||
|
sleep(retry_delay);
|
||||||
|
if (retry_delay < 60) retry_delay *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!g_nconns) return 1;
|
||||||
|
|
||||||
conn_t *primary = &g_conns[0];
|
conn_t *primary = &g_conns[0];
|
||||||
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
||||||
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
||||||
|
|||||||
+24
-13
@@ -41,7 +41,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.3"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# 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)
|
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] = []
|
connections: List[AsyncConnection] = []
|
||||||
conn_id = 1
|
conn_id = 1
|
||||||
for host in args.hosts:
|
_retry_delay = 5
|
||||||
try:
|
while _running and not connections:
|
||||||
addrs = socket.getaddrinfo(host, port, 0, 0, socket.SOL_UDP)
|
for host in args.hosts:
|
||||||
except socket.gaierror as e:
|
try:
|
||||||
log.error("cannot resolve %s: %s", host, e)
|
addrs = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
|
||||||
continue
|
except socket.gaierror as e:
|
||||||
for ai in addrs:
|
log.warning("cannot resolve %s: %s — retrying in %ds", host, e, _retry_delay)
|
||||||
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
continue
|
||||||
if await conn.open():
|
for ai in addrs:
|
||||||
connections.append(conn)
|
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
||||||
conn_id += 1
|
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:
|
if not connections:
|
||||||
log.error("no connections established")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Boot / one-shot message
|
# 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("-d", "--daemon", action="store_true", help="Run as daemon")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
|
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)")
|
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
|
|||||||
import hbdclass
|
import hbdclass
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"config": {"watchhosts": [], "dyndnshosts": []},
|
"config": {"watchhosts": []},
|
||||||
"hbdclass": hbdclass,
|
"hbdclass": hbdclass,
|
||||||
"log": dummy_noop,
|
"log": dummy_noop,
|
||||||
"email": dummy_noop,
|
"email": dummy_noop,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
def __init__(self, lastbeat):
|
||||||
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHost:
|
||||||
|
def __init__(self, name="myhost", owner=None, managers=None,
|
||||||
|
connections=None, os_data=None, plugin_data=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = managers or []
|
||||||
|
self.connections = connections or {}
|
||||||
|
self._os_data = os_data
|
||||||
|
self.plugin_data = plugin_data or {}
|
||||||
|
|
||||||
|
def get_latest_plugin_data(self, plugin_name):
|
||||||
|
if plugin_name == "os_info" and self._os_data is not None:
|
||||||
|
return (1234567890.0, self._os_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_basic_fields():
|
||||||
|
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] == "alice"
|
||||||
|
assert result["managers"] == ["bob", "carol"]
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_no_owner():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] is None
|
||||||
|
assert result["managers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_reads_hbc_from_os_info():
|
||||||
|
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] == "5.3.0"
|
||||||
|
assert result["hbc_type"] == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_hbc_none_when_no_os_info():
|
||||||
|
host = _FakeHost(os_data=None)
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConn(1000.0),
|
||||||
|
"IPv6": _FakeConn(2000.0),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_none_when_no_connections():
|
||||||
|
host = _FakeHost(connections={})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_none_without_checker():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=None)
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_sorted_by_metric():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||||
|
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||||
|
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {
|
||||||
|
"memory_monitor.memory_percent": tc_mem,
|
||||||
|
"cpu_monitor.cpu_percent": tc_cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
assert len(result["thresholds"]) == 2
|
||||||
|
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||||
|
assert result["thresholds"][0]["warning"] == 80.0
|
||||||
|
assert result["thresholds"][0]["critical"] == 95.0
|
||||||
|
assert result["thresholds"][0]["operator"] == ">"
|
||||||
|
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_threshold_null_warning_critical():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["warning"] is None
|
||||||
|
assert result["thresholds"][0]["critical"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_nagios_operator_serialized():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["operator"] == "nagios"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_suffix_matched_metrics():
|
||||||
|
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost(
|
||||||
|
connections={},
|
||||||
|
os_data=None,
|
||||||
|
)
|
||||||
|
# Simulate plugin_data with both percent and swap_percent fields
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {
|
||||||
|
"percent": 80.0,
|
||||||
|
"swap_percent": 25.0,
|
||||||
|
"available_mb": 2000,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["metric"] == "memory_monitor.percent"
|
||||||
|
assert t["covers"] == ["memory_monitor.swap_percent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_empty_when_exact_matches_only():
|
||||||
|
"""No covers when all plugin fields match their threshold exactly."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["covers"] == []
|
||||||
@@ -83,3 +83,41 @@ def test_put_users_me_notification_channels(tmp_path):
|
|||||||
configio.write_config(str(cfg), data)
|
configio.write_config(str(cfg), data)
|
||||||
result = configio.read_roundtrip(str(cfg))
|
result = configio.read_roundtrip(str(cfg))
|
||||||
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
|
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"}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
|
|||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
for s in sections:
|
for s in sections:
|
||||||
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
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():
|
def test_sections_have_api_section():
|
||||||
@@ -45,16 +45,47 @@ def test_network_section_has_editable_fields():
|
|||||||
def test_yaml_sections_have_correct_mode():
|
def test_yaml_sections_have_correct_mode():
|
||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
||||||
assert "channels" in yaml_sections
|
assert "channels" not in yaml_sections # now uses "channels" mode
|
||||||
assert "hosts" in yaml_sections
|
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
||||||
assert "thresholds" in yaml_sections
|
assert "thresholds" in yaml_sections
|
||||||
assert "dns" 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["thresholds"]["api_section"] == "thresholds"
|
||||||
assert yaml_sections["dns"]["api_section"] == "dns"
|
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():
|
def test_oauth_section_exists():
|
||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
||||||
|
|||||||
Reference in New Issue
Block a user