Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a42d783c0c | ||
|
|
18e33656c0 | ||
|
|
4ac5a34a43 | ||
|
|
6d39c5be47 | ||
|
|
cdb2183989 | ||
|
|
41c78e8e34 | ||
|
|
acf1eb8804 | ||
|
|
49b631f02d | ||
|
|
8204737ea6 | ||
|
|
5b2fc8fd46 | ||
|
|
70c3e93684 | ||
|
|
52968201fa | ||
|
|
a7b698e6b9 | ||
|
|
136b10239f | ||
|
|
1e46f33648 | ||
|
|
4c3a63e645 | ||
|
|
1f4dd7cb1f | ||
|
|
ae3f2fc70f | ||
|
|
4414967bdc | ||
|
|
e3b0e5041f | ||
|
|
92982c0ca7 | ||
|
|
00fdbcaa5b | ||
|
|
600e68b509 | ||
|
|
b26e853a1c | ||
|
|
36adad5b3b | ||
|
|
7591268a89 | ||
|
|
7afc06a329 | ||
|
|
a3f303e6ba | ||
|
|
16c2922bea | ||
|
|
0343eb4a24 | ||
|
|
fb5570155b | ||
|
|
8d268adf70 | ||
|
|
5d9f161706 | ||
|
|
067feacd59 | ||
|
|
c564fafe73 | ||
|
|
8eeae48964 | ||
|
|
880dc0e33f | ||
|
|
f06b2ef9e8 | ||
|
|
447b9574a3 | ||
|
|
e98cca22f7 | ||
|
|
5fe069a1a2 | ||
|
|
ca2868a888 | ||
|
|
ddb1b2b875 | ||
|
|
39c4d45bd7 | ||
|
|
07fefab861 | ||
|
|
6b4524b30d | ||
|
|
a149a1e285 | ||
|
|
dd939ab86e | ||
|
|
5c6f462081 | ||
|
|
98b50dbbf4 | ||
|
|
960822918a | ||
|
|
a376ebba8d | ||
|
|
f603ef9bd2 | ||
|
|
731545dd0e | ||
|
|
356c77b0b8 | ||
|
|
6282077fe0 | ||
|
|
ddd857173b | ||
|
|
f46f725d12 | ||
|
|
3da6976b53 | ||
|
|
3a0c48e32b | ||
|
|
cf6e19704f | ||
|
|
b0addd7c67 | ||
|
|
32680d34a4 | ||
|
|
a7abdcb5c5 | ||
|
|
7bab15ae52 |
@@ -16,3 +16,4 @@ uv.lock
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
rndc-key
|
rndc-key
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -2,6 +2,99 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here, organized by release.
|
All notable changes to this project are documented here, organized by release.
|
||||||
|
|
||||||
|
## [5.4.2]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- preserve chart history across reconnects, show gaps for missing data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.4.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- live-refresh Connectivity section and RTT charts every 30s
|
||||||
|
- render RTT history chart per connection in Connectivity section
|
||||||
|
- record RTT history per heartbeat for charting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- correct empty-state copy on Host Overview page
|
||||||
|
- stop synthetic rtt_* keys from masking real plugin-data checks
|
||||||
|
- use the latest RTT sample, not the oldest, in host info API
|
||||||
|
- hide synthetic rtt history keys from plugin accordion list
|
||||||
|
- remove extra blank line to pass flake8 E303 check
|
||||||
|
- clear flap state when a host is dropped
|
||||||
|
- correct plugin import path in docs, wire up orphaned footer template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.4.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- gate remote command execution behind allow_remote_command
|
||||||
|
- flapping detection suppresses notification storms
|
||||||
|
- remove log section from live dashboard (moved to /log)
|
||||||
|
- dedicated /log page with journal-backed history and nav item
|
||||||
|
- GET /api/0/log — paged, filtered events journal API
|
||||||
|
- eventlog writes to dedicated events journal; init/backfill/close wiring
|
||||||
|
- events journal read path with filters and backward pagination
|
||||||
|
- events journal write primitives (log_event, backfill, get_events_journal)
|
||||||
|
- Live Dashboard redesign — 6 columns, hover details, last-alert column
|
||||||
|
- extend the record-row design system to Host Overview, Alerts, and About
|
||||||
|
- unified record-row redesign of the settings page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- harden events log — non-dict ring entries, off-loop journal reads, no default-config singleton
|
||||||
|
- skip journal scheduling when the event loop is not running
|
||||||
|
- dedup log rows between API seed and websocket tail
|
||||||
|
- journal startup/shutdown lifecycle events (fired outside journal lifetime)
|
||||||
|
- drop typing names not yet used from journal.py import
|
||||||
|
- settings toolbar sticks below the site nav instead of scrolling away
|
||||||
|
- settings page not scrollable — restore html/body overflow override
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.3.12]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- retire per-channel private flag on profile page
|
||||||
|
- settings UI renders per-user edit rights and owner controls
|
||||||
|
- settings page accessible to all authenticated users
|
||||||
|
- per-user filtering of settings sections
|
||||||
|
- scoped non-admin saves through POST /api/0/config
|
||||||
|
- threshold form payload carries per-config owner
|
||||||
|
- channel ownership rule — owner-presence means private, admin promote/demote
|
||||||
|
- scoped threshold-config merge for non-admin saves
|
||||||
|
- scoped host merge for non-admin config saves
|
||||||
|
- ownership visibility helpers for config entities
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- users loop clobbered requesting username in settings filtering
|
||||||
|
- type annotations for mypy parity with master
|
||||||
|
- don't re-announce boot/shutdown on SIGHUP restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.3.11]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- add Windows hbc client with PyInstaller spec and NSSM install script
|
||||||
|
- clear alerts for individual plugin metrics that disappear between samples
|
||||||
|
- show alerts for all hosts on Alerts page, not just watched
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- declare plugin interval so stale data waits two full intervals
|
||||||
|
- cap event buffer and replay only recent messages on dashboard connect
|
||||||
|
- strip plugin timers when pickling Host to prevent save failure
|
||||||
|
- correct zero-safe pathconf checks and connectivity prefix match
|
||||||
|
- address security vulnerabilities from audit
|
||||||
|
- don't purge connectivity/rtt alerts in purge_stale_alerts
|
||||||
|
- restore connectivity alerts for overdue/unknown/down hosts on startup
|
||||||
|
- clear plugin data and timers on connection UP transition
|
||||||
|
- restore host link from Dashboard to Host Overview
|
||||||
|
- don't set stale timer until two plugin samples establish real interval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [5.3.10]
|
## [5.3.10]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,97 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Working principles
|
||||||
|
|
||||||
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||||
2. Minimum code that solves the problem. Nothing speculative.
|
2. Minimum code that solves the problem. Nothing speculative.
|
||||||
3. Touch only what you must. Clean up only your own mess.
|
3. Touch only what you must. Clean up only your own mess.
|
||||||
4. Define success criteria. Loop until verified.
|
4. Define success criteria. Loop until verified.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
pytest -q # all tests
|
||||||
|
pytest tests/test_threshold.py # single file
|
||||||
|
pytest -k test_name # single test
|
||||||
|
|
||||||
|
# Lint and type check
|
||||||
|
tox -e lint # flake8 over hbd/ and tests/
|
||||||
|
tox -e mypy # mypy over hbd/
|
||||||
|
|
||||||
|
# Install for development
|
||||||
|
pip install -e ".[all,dev]"
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
python -m hbd.server.cli serve -c .hb.yaml -f -v
|
||||||
|
|
||||||
|
# Generate a password hash
|
||||||
|
hbd passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests live in `tests/` (pytest, imported as a package). A second directory `test/` contains only TLS fixtures (`.pem` files), not test code.
|
||||||
|
|
||||||
|
Line length: 111 characters (`black`, `flake8`, both configured).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system has two components: `hbc` (client) and `hbd` (server), both in the `hbd` Python package.
|
||||||
|
|
||||||
|
```
|
||||||
|
hbd/
|
||||||
|
common/ # proto.py (encode/decode), utils.py
|
||||||
|
client/ # hbc: plugins/, main.py, config.py, plugin.py
|
||||||
|
server/ # hbd: all server modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server module map
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|---|---|
|
||||||
|
| `cli.py` | Argument parsing, entry point, daemon startup |
|
||||||
|
| `main.py` | Asyncio runtime: starts UDP, HTTP, WebSocket; pickle save/load |
|
||||||
|
| `hbdclass.py` | `Host` and `Connection` domain objects; DNS queue |
|
||||||
|
| `udp.py` | UDP datagram listener; processes `HTB` and `PLG` messages; sets/resets overdue timers |
|
||||||
|
| `http.py` | aiohttp web server; all REST API routes (`/api/0/`) and page routes |
|
||||||
|
| `ws.py` | WebSocket broadcast to connected dashboard clients |
|
||||||
|
| `notify.py` | Notification dispatch (Pushover, email, Mattermost, Matrix, Signal, SMS); `eventlog()` is the single choke-point for all alert events |
|
||||||
|
| `threshold.py` | Threshold evaluation against plugin metrics; state transitions (OK/WARNING/CRITICAL/UNKNOWN) |
|
||||||
|
| `monitor.py` | Timer cleanup on shutdown (reachability is event-driven via timers in `udp.py`, not polled) |
|
||||||
|
| `data.py` | Shared in-memory message ring buffer |
|
||||||
|
| `journal.py` | JSONL message journal with size-based rotation |
|
||||||
|
| `users.py` | Session management, password hashing (PBKDF2), role checks |
|
||||||
|
| `config.py` | Config loading and defaults |
|
||||||
|
| `configio.py` | Config file read/write via `ruamel.yaml` (preserves comments) |
|
||||||
|
| `settings.py` | Settings sections for the web UI settings page |
|
||||||
|
| `dns.py` | `nsupdate` integration for dynamic DNS |
|
||||||
|
| `oauth.py` | OAuth2 login (Gitea) |
|
||||||
|
|
||||||
|
### Key data flows
|
||||||
|
|
||||||
|
**Heartbeat received:** `udp.py` decodes the `HTB` datagram → updates `Connection` state in `hbdclass.py` → resets overdue asyncio timer → broadcasts via `ws.py` → `notify.py` fires connectivity alerts on state change.
|
||||||
|
|
||||||
|
**Plugin data received:** `udp.py` decodes `PLG` datagram → stores on `Host` → `threshold.py` evaluates against configured thresholds → `notify.py` fires threshold alerts on state transitions.
|
||||||
|
|
||||||
|
**State persistence:** `Host.hosts` dict + `data.msgs` ring + active sessions are pickled every 5 minutes and on clean shutdown. Asyncio timers are stripped before pickling (`Connection.__getstate__`).
|
||||||
|
|
||||||
|
**Config reload:** SIGHUP → `configio.py` re-reads YAML → live-updates hosts, thresholds, users, notification channels. Port/cert/pickle/journal changes require a full restart.
|
||||||
|
|
||||||
|
### Client plugin system
|
||||||
|
|
||||||
|
Plugins in `hbd/client/plugins/` subclass `InfoPlugin` (collected once, on demand) or `MonitorPlugin` (periodic). `initialize()` returns `False` to self-disable. Data is sent as `PLG` UDP messages.
|
||||||
|
|
||||||
|
`hbc_mini.py` (scripts/) and `hbc_mini.c` (scripts/c/) are standalone single-file clients with no external dependencies.
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
All UDP messages: `!<ID>: <zlib-compressed key=value payload>`. Encoding in `hbd/common/proto.py`. Lists/dicts encoded as JSON with `@` prefix; booleans as `1`/`0`.
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
Jinja2 templates in `hbd/server/templates/`. Static assets in `hbd/server/static/`. Live pages (`/live`, `/plugins`) use WebSocket connections for real-time push.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
Gitea Actions workflow at `.gitea/workflows/release.yml`.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
|
|||||||
└────────────────────┘ └────────────────────────────┘
|
└────────────────────┘ └────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Package:** `hbd` v5.3.10
|
**Package:** `hbd` v5.4.2
|
||||||
**Python:** 3.11+
|
**Python:** 3.11+
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
@@ -116,6 +116,11 @@ dyndomains:
|
|||||||
# Threshold alert re-notification interval (seconds)
|
# Threshold alert re-notification interval (seconds)
|
||||||
threshold_renotify_interval: 3600
|
threshold_renotify_interval: 3600
|
||||||
|
|
||||||
|
# Flap detection — silence a service/host after flap_count warning or critical
|
||||||
|
# notifications within flap_interval minutes (flap_count: 0 disables)
|
||||||
|
flap_count: 5
|
||||||
|
flap_interval: 10
|
||||||
|
|
||||||
# Notification channels
|
# Notification channels
|
||||||
notification_channels:
|
notification_channels:
|
||||||
pushover_ops:
|
pushover_ops:
|
||||||
@@ -207,6 +212,9 @@ hb_port: 50003 # Server UDP port
|
|||||||
interval: 10 # Heartbeat interval (seconds)
|
interval: 10 # Heartbeat interval (seconds)
|
||||||
owner: alice # Optional: claim ownership of this host
|
owner: alice # Optional: claim ownership of this host
|
||||||
|
|
||||||
|
allow_remote_command: false # Execute shell commands sent by the server in CMD packets.
|
||||||
|
# Default false — see Remote command execution below.
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
interval: 300 # Override collection interval
|
interval: 300 # Override collection interval
|
||||||
@@ -237,6 +245,27 @@ plugins:
|
|||||||
- If a connection fails to open at startup, IPv6 connections are dropped after 3 consecutive failures. IPv4 connections retry indefinitely.
|
- If a connection fails to open at startup, IPv6 connections are dropped after 3 consecutive failures. IPv4 connections retry indefinitely.
|
||||||
- In daemon mode (`-d`), all log output goes to syslog (`LOG_DAEMON` facility).
|
- In daemon mode (`-d`), all log output goes to syslog (`LOG_DAEMON` facility).
|
||||||
|
|
||||||
|
### Remote command execution
|
||||||
|
|
||||||
|
The server can queue a shell command for a host, delivered in a `CMD` packet. Because
|
||||||
|
heartbeat packets are unauthenticated UDP, the client only runs those commands when the
|
||||||
|
host opts in:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
allow_remote_command: true
|
||||||
|
```
|
||||||
|
|
||||||
|
With the default `false`, the command is logged and refused, and the client replies
|
||||||
|
`Refused: allow_remote_command is false` — visible in the server's event log under the
|
||||||
|
`command` service, so a queued command never fails silently. When enabled, `hbc` logs a
|
||||||
|
warning at startup naming the risk. `SIGHUP` re-execs the client, so a config change takes
|
||||||
|
effect on reload.
|
||||||
|
|
||||||
|
All four clients enforce this: `hbc`, `hbc_windows.py`, `hbc_mini.py`, and the C
|
||||||
|
`hbc_mini` (which reads the same key from `~/.hbc.json` and needs a rebuild to pick the
|
||||||
|
change up). It does not gate `UPD` (self-update via `hb_install.sh`), which remains
|
||||||
|
ungated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UDP Protocol
|
## UDP Protocol
|
||||||
@@ -254,7 +283,7 @@ Payload format: `key=value;key=value;...`
|
|||||||
| `HTB` | client → server | Heartbeat (name, timestamp, RTT, acks, interval) |
|
| `HTB` | client → server | Heartbeat (name, timestamp, RTT, acks, interval) |
|
||||||
| `PLG` | client → server | Plugin data (plugin name + metrics) |
|
| `PLG` | client → server | Plugin data (plugin name + metrics) |
|
||||||
| `ACK` | server → client | Acknowledgment |
|
| `ACK` | server → client | Acknowledgment |
|
||||||
| `CMD` | server → client | Execute a shell command on the client |
|
| `CMD` | server → client | Execute a shell command on the client (requires `allow_remote_command`) |
|
||||||
| `UPD` | server → client | Trigger self-update via `hb_install.sh` |
|
| `UPD` | server → client | Trigger self-update via `hb_install.sh` |
|
||||||
|
|
||||||
Value encoding:
|
Value encoding:
|
||||||
@@ -400,6 +429,8 @@ hosts:
|
|||||||
|
|
||||||
Notifications are sent on state transitions (OK → WARNING, WARNING → CRITICAL, CRITICAL → OK). De-escalations (CRITICAL → WARNING) do not trigger a notification. Ongoing alerts generate a re-notification every `threshold_renotify_interval` seconds (default: 3600). Alerts can be acknowledged via the web UI or API to suppress re-notifications.
|
Notifications are sent on state transitions (OK → WARNING, WARNING → CRITICAL, CRITICAL → OK). De-escalations (CRITICAL → WARNING) do not trigger a notification. Ongoing alerts generate a re-notification every `threshold_renotify_interval` seconds (default: 3600). Alerts can be acknowledged via the web UI or API to suppress re-notifications.
|
||||||
|
|
||||||
|
A service or host that exceeds `flap_count` warning/critical notifications within `flap_interval` minutes is marked **flapping**: the tripping notification carries `Now flapping!! No more messages!` and further notifications are suppressed until it stays OK for `flap_interval` minutes. Flapping hosts are badged on the live dashboard; the event log keeps recording throughout. See [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md#flap-detection).
|
||||||
|
|
||||||
### RTT thresholds
|
### RTT thresholds
|
||||||
|
|
||||||
The server measures heartbeat round-trip time and supports RTT thresholds using the same format:
|
The server measures heartbeat round-trip time and supports RTT thresholds using the same format:
|
||||||
|
|||||||
@@ -406,6 +406,21 @@ Potential improvements for future versions:
|
|||||||
- Journal file encryption
|
- Journal file encryption
|
||||||
- Signed journal entries
|
- Signed journal entries
|
||||||
|
|
||||||
|
## Events journal
|
||||||
|
|
||||||
|
Alert/connectivity events shown on the `/log` page are written to a second,
|
||||||
|
dedicated journal (one JSON object per line, same rotation mechanism):
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `events_journal_file` | `events.journal` | Filename inside `journal_dir` |
|
||||||
|
| `events_journal_max_size` | 10 MB | Rotation threshold |
|
||||||
|
| `events_journal_max_backups` | 10 | Rotated files kept |
|
||||||
|
|
||||||
|
`journal_dir` and `journal_enabled` are shared with the message journal.
|
||||||
|
On startup, if the events journal file is empty, it is seeded once from the
|
||||||
|
pickled in-memory message ring so history survives the upgrade.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [Configuration Guide](../hbd/config.py) - Full configuration options
|
- [Configuration Guide](../hbd/config.py) - Full configuration options
|
||||||
|
|||||||
+54
-13
@@ -9,6 +9,7 @@ Notifications are dispatched to the **owner and managers** of a host, each via t
|
|||||||
```
|
```
|
||||||
Alert event (udp.py / threshold.py)
|
Alert event (udp.py / threshold.py)
|
||||||
└─ notify.send_notification(host_name, Notification)
|
└─ notify.send_notification(host_name, Notification)
|
||||||
|
├─ flap.observe(host, service, level) → pass | trip | suppress
|
||||||
├─ look up host.owner + host.managers
|
├─ look up host.owner + host.managers
|
||||||
├─ for each user → user.notification_channels
|
├─ for each user → user.notification_channels
|
||||||
└─ for each channel → _dispatch_to_channel (filtered by min_level)
|
└─ for each channel → _dispatch_to_channel (filtered by min_level)
|
||||||
@@ -19,6 +20,7 @@ Every notification carries:
|
|||||||
- **body** — detail message (metric value, threshold, duration)
|
- **body** — detail message (metric value, threshold, duration)
|
||||||
- **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`)
|
- **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`)
|
||||||
- **level** — `RECOVER | WARNING | CRITICAL | INFO`
|
- **level** — `RECOVER | WARNING | CRITICAL | INFO`
|
||||||
|
- **service** — flap-detection key within the host (empty = the host itself)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -32,15 +34,16 @@ base_url: https://hbd.example.com
|
|||||||
|
|
||||||
### Channel definitions
|
### Channel definitions
|
||||||
|
|
||||||
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
|
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Ownership is the single visibility signal:
|
||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
|
| `owner` | *(absent)* | Owning username. Present = private to that user; absent = global. |
|
||||||
| `private` | `false` | When `true`, only the owner can see and select this channel. |
|
|
||||||
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
|
| `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:
|
(The former `private` flag is retired; leftover `private` keys are ignored and dropped on the next edit.)
|
||||||
|
|
||||||
|
**Global channels** (no `owner`; set in the config file or by an admin) can be selected by all users but edited only by admins:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -90,7 +93,7 @@ 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`:
|
**User-created channels** are written by authenticated users through the API, their profile page, or the settings page. They carry an `owner` field and are private to that user:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -99,17 +102,18 @@ notification_channels:
|
|||||||
type: pushover
|
type: pushover
|
||||||
token: personal-token
|
token: personal-token
|
||||||
user: personal-key
|
user: personal-key
|
||||||
owner: alice # created by alice
|
owner: alice # private to alice
|
||||||
private: true # only alice can see this channel
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Channel visibility
|
### Channel visibility
|
||||||
|
|
||||||
| Channel | Who can see / select it |
|
| Channel | Who can see / select it | Who can edit it |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| No `private` field (or `private: false`) | All users |
|
| No `owner` (global) | All users | Admins |
|
||||||
| `private: true` | Only the `owner` |
|
| `owner` set (private) | Only the `owner` | The owner |
|
||||||
| Any channel | Admins always see everything |
|
| Any channel | Admins always see everything | Admins |
|
||||||
|
|
||||||
|
Admins can **promote** a private channel to global by clearing its owner on the settings page, or **demote** a global channel by assigning an owner.
|
||||||
|
|
||||||
### Users with notification channels
|
### Users with notification channels
|
||||||
|
|
||||||
@@ -266,6 +270,43 @@ min_level: WARNING
|
|||||||
|
|
||||||
Reminder notifications (re-notify) are sent only for CRITICAL level alerts.
|
Reminder notifications (re-notify) are sent only for CRITICAL level alerts.
|
||||||
|
|
||||||
|
## Flap detection
|
||||||
|
|
||||||
|
A check that toggles between OK and alerting produces a notification per swing. Flap
|
||||||
|
detection silences it after the first few.
|
||||||
|
|
||||||
|
A **`(host, service)`** pair is flapping once it exceeds `flap_count` WARNING/CRITICAL
|
||||||
|
notifications within `flap_interval` minutes. Threshold alerts key on their metric path,
|
||||||
|
so a flapping disk check does not silence an unrelated CPU alert; connectivity, boot and
|
||||||
|
shutdown events key on the host itself.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
flap_count: 5 # notifications within the window that trip flapping (0 disables)
|
||||||
|
flap_interval: 10 # minutes — both the counting window and the quiet window
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
|
||||||
|
| Event | Effect |
|
||||||
|
|---|---|
|
||||||
|
| Alerts 1..`flap_count` within the window | Delivered normally |
|
||||||
|
| Alert `flap_count + 1` | Delivered with ` Now flapping!! No more messages!` appended to the body |
|
||||||
|
| Every notification after that | Dropped — including RECOVER and INFO |
|
||||||
|
| RECOVER while flapping | Dropped, and starts the `flap_interval` quiet window |
|
||||||
|
| WARNING/CRITICAL during the quiet window | Restarts the quiet window; still flapping |
|
||||||
|
| Quiet window elapses | Flapping ends **silently** — no notification |
|
||||||
|
|
||||||
|
Only outbound notifications are suppressed. `notify.eventlog` keeps recording every event,
|
||||||
|
so the journal and the `/log` page retain the full history of the flap.
|
||||||
|
|
||||||
|
Flapping pairs appear in each host's `stateinfo()` under `flapping` (a list of service
|
||||||
|
keys; `""` means the host itself) and render as an amber **flapping** badge next to the
|
||||||
|
host name on the live dashboard, with the affected services in its tooltip.
|
||||||
|
|
||||||
|
State lives at module level in `hbd/server/flap.py` and is never pickled — a server restart
|
||||||
|
starts every check with a clean slate. Hosts with `watch: false` never reach the
|
||||||
|
notification path, so they never flap.
|
||||||
|
|
||||||
## API reference
|
## API reference
|
||||||
|
|
||||||
### `send_notification(host_name, notif) -> dict`
|
### `send_notification(host_name, notif) -> dict`
|
||||||
@@ -299,7 +340,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
|
- If a user can't select a channel, check whether it has an `owner` other than that user
|
||||||
|
|
||||||
**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
|
||||||
|
|||||||
+10
-10
@@ -21,7 +21,7 @@ Heartbeat's plugin system is designed to be simple yet powerful. Plugins are Pyt
|
|||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
- **Plugin Registry**: Central registry that manages all loaded plugins
|
- **Plugin Registry**: Central registry that manages all loaded plugins
|
||||||
- **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/plugins/` directory
|
- **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/client/plugins/` directory
|
||||||
- **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics)
|
- **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics)
|
||||||
- **Async/Await**: All plugin methods are async for non-blocking operation
|
- **Async/Await**: All plugin methods are async for non-blocking operation
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Decide whether your plugin collects static information (InfoPlugin) or dynamic m
|
|||||||
|
|
||||||
### Step 2: Create Plugin File
|
### Step 2: Create Plugin File
|
||||||
|
|
||||||
Create a new Python file in `hbd/plugins/` directory:
|
Create a new Python file in `hbd/client/plugins/` directory:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""
|
"""
|
||||||
@@ -82,7 +82,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
psutil = None
|
psutil = None
|
||||||
|
|
||||||
from hbd.plugin import MonitorPlugin # or InfoPlugin
|
from hbd.client.plugin import MonitorPlugin # or InfoPlugin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ from pathlib import Path
|
|||||||
# Add parent directory to path
|
# Add parent directory to path
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from hbd.plugins.my_awesome_plugin import MyAwesomePlugin
|
from hbd.client.plugins.my_awesome_plugin import MyAwesomePlugin
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
# Create plugin instance
|
# Create plugin instance
|
||||||
@@ -224,7 +224,7 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
|
|||||||
|
|
||||||
```
|
```
|
||||||
1. Plugin Discovery
|
1. Plugin Discovery
|
||||||
└─> Loader scans hbd/plugins/ directory
|
└─> Loader scans hbd/client/plugins/ directory
|
||||||
└─> Finds Python files (except those starting with _)
|
└─> Finds Python files (except those starting with _)
|
||||||
└─> Imports modules
|
└─> Imports modules
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ Document your plugin thoroughly:
|
|||||||
### Example 1: Simple InfoPlugin
|
### Example 1: Simple InfoPlugin
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import InfoPlugin
|
from hbd.client.plugin import InfoPlugin
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
class SimpleInfoPlugin(InfoPlugin):
|
class SimpleInfoPlugin(InfoPlugin):
|
||||||
@@ -406,7 +406,7 @@ plugin = SimpleInfoPlugin
|
|||||||
### Example 2: MonitorPlugin with State
|
### Example 2: MonitorPlugin with State
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
import time
|
import time
|
||||||
|
|
||||||
class CounterPlugin(MonitorPlugin):
|
class CounterPlugin(MonitorPlugin):
|
||||||
@@ -442,7 +442,7 @@ plugin = CounterPlugin
|
|||||||
### Example 3: Plugin with External Command
|
### Example 3: Plugin with External Command
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from hbd.plugin import MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
class CommandPlugin(MonitorPlugin):
|
class CommandPlugin(MonitorPlugin):
|
||||||
@@ -561,7 +561,7 @@ python -m hbd.hbc -c test_config.yaml --verbose
|
|||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [Plugin Framework Source](../hbd/plugin.py) - Core plugin implementation
|
- [Plugin Framework Source](../hbd/client/plugin.py) - Core plugin implementation
|
||||||
- [Built-in Plugins](../hbd/plugins/) - Examples of working plugins
|
- [Built-in Plugins](../hbd/client/plugins/) - Examples of working plugins
|
||||||
- [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins
|
- [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins
|
||||||
- [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference
|
- [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference
|
||||||
|
|||||||
+10
-1
@@ -19,6 +19,15 @@ Users are defined in the server config file. Each host can have an **owner**, ze
|
|||||||
|
|
||||||
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
|
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
|
||||||
|
|
||||||
|
### Settings page access
|
||||||
|
|
||||||
|
All authenticated users may open `/settings`. Admins see every section; non-admins see only **Notification Channels**, **Hosts**, and **Threshold Configurations**, filtered to global items plus what they own or manage:
|
||||||
|
|
||||||
|
- **Owners** may add hosts, delete their hosts, edit host settings, and change access lists (managers/monitors, ownership transfer).
|
||||||
|
- **Managers** may edit host settings (watch, dyndns, channel and threshold assignments) but not access lists, and may not delete hosts.
|
||||||
|
- Anyone may create private notification channels and threshold configs (owned by them) and assign them — or global ones — to their hosts.
|
||||||
|
- Admins promote a private channel/threshold config to global by clearing its owner, or demote by assigning one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -200,7 +209,7 @@ Update the current user's profile. All fields are optional — send only what yo
|
|||||||
```json
|
```json
|
||||||
{ "notification_channels": ["pushover_ops", "email_ops"] }
|
{ "notification_channels": ["pushover_ops", "email_ops"] }
|
||||||
```
|
```
|
||||||
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
|
Only channels visible to the user (global + own) are accepted; others are silently dropped.
|
||||||
|
|
||||||
**Change password:**
|
**Change password:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.10"
|
__version__ = "5.4.2"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ CLIENT_DEFAULTS = {
|
|||||||
# Host identity
|
# Host identity
|
||||||
"owner": None, # Optional username to set as this host's owner on the server
|
"owner": None, # Optional username to set as this host's owner on the server
|
||||||
|
|
||||||
|
# Security
|
||||||
|
"allow_remote_command": False, # Execute shell commands received in CMD packets from the server
|
||||||
|
|
||||||
# Runtime flags
|
# Runtime flags
|
||||||
"foreground": False,
|
"foreground": False,
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
|
|||||||
+29
-9
@@ -37,6 +37,10 @@ dorestart = False
|
|||||||
shutdown_event: Optional[asyncio.Event] = None
|
shutdown_event: Optional[asyncio.Event] = None
|
||||||
active_tasks: List[asyncio.Task] = []
|
active_tasks: List[asyncio.Task] = []
|
||||||
|
|
||||||
|
# Set from config in async_main. Off by default: a CMD packet is an unauthenticated
|
||||||
|
# UDP datagram, so executing one must be opted into per host.
|
||||||
|
allow_remote_command = False
|
||||||
|
|
||||||
|
|
||||||
class AsyncConnection:
|
class AsyncConnection:
|
||||||
"""Async UDP connection to a heartbeat server."""
|
"""Async UDP connection to a heartbeat server."""
|
||||||
@@ -183,7 +187,7 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
|||||||
|
|
||||||
|
|
||||||
async def handle_command(conn: AsyncConnection, msg: dict):
|
async def handle_command(conn: AsyncConnection, msg: dict):
|
||||||
"""Execute a command received from server."""
|
"""Execute a command received from server, if allow_remote_command is set."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
cmd = msg.get("cmd", "")
|
cmd = msg.get("cmd", "")
|
||||||
@@ -191,6 +195,15 @@ async def handle_command(conn: AsyncConnection, msg: dict):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.command")
|
logger = logging.getLogger("hbc.command")
|
||||||
|
|
||||||
|
if not allow_remote_command:
|
||||||
|
logger.warning(f"Refused command (allow_remote_command is false): {cmd}")
|
||||||
|
await conn.sendto({
|
||||||
|
"service": "command",
|
||||||
|
"msg": "Refused: allow_remote_command is false",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(f"Executing command: {cmd}")
|
logger.info(f"Executing command: {cmd}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -356,7 +369,8 @@ async def _info_plugin_refresh_loop(conn: AsyncConnection, info_plugins: List):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto(
|
||||||
|
{"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
logger.info(f"Resent {plugin.name} data")
|
logger.info(f"Resent {plugin.name} data")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
||||||
@@ -377,8 +391,8 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
# Create PLG message with plugin name
|
# Create PLG message with plugin name and declared interval
|
||||||
plugin_msg = {"plugin": plugin.name, **data}
|
plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
|
||||||
await conn.sendto(plugin_msg, "PLG")
|
await conn.sendto(plugin_msg, "PLG")
|
||||||
logger.info(f"Sent {plugin.name} data")
|
logger.info(f"Sent {plugin.name} data")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -430,7 +444,7 @@ async def plugin_collector_interval(
|
|||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
# Don't use encode_plugin_data - create dict directly
|
# Don't use encode_plugin_data - create dict directly
|
||||||
plugin_msg = {"plugin": plugin.name, **data}
|
plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
|
||||||
await conn.sendto(plugin_msg, "PLG")
|
await conn.sendto(plugin_msg, "PLG")
|
||||||
logger.debug(f"Sent {plugin.name} data")
|
logger.debug(f"Sent {plugin.name} data")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -485,7 +499,8 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
logger.info("Cleaning up connections")
|
logger.info("Cleaning up connections")
|
||||||
|
|
||||||
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
||||||
if target and send_shutdown:
|
# A SIGHUP restart is not a host shutdown, so don't announce one.
|
||||||
|
if target and send_shutdown and not dorestart:
|
||||||
try:
|
try:
|
||||||
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -499,11 +514,12 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
|
|
||||||
async def async_main(args, config):
|
async def async_main(args, config):
|
||||||
"""Async main function."""
|
"""Async main function."""
|
||||||
global running, shutdown_event, active_tasks, send_shutdown
|
global running, shutdown_event, active_tasks, send_shutdown, allow_remote_command
|
||||||
|
|
||||||
# Create shutdown event
|
# Create shutdown event
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
active_tasks = []
|
active_tasks = []
|
||||||
|
allow_remote_command = bool(config.get("allow_remote_command", False))
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.main")
|
logger = logging.getLogger("hbc.main")
|
||||||
|
|
||||||
@@ -517,6 +533,8 @@ async def async_main(args, config):
|
|||||||
interval = config.get("interval", INTERVAL)
|
interval = config.get("interval", INTERVAL)
|
||||||
|
|
||||||
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")
|
||||||
|
if allow_remote_command:
|
||||||
|
logger.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
||||||
|
|
||||||
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
@@ -563,7 +581,6 @@ async def async_main(args, config):
|
|||||||
boot_msg = {}
|
boot_msg = {}
|
||||||
if args.boot:
|
if args.boot:
|
||||||
boot_msg["boot"] = 1
|
boot_msg["boot"] = 1
|
||||||
args.boot = False # Clear boot flag so we don't send it again in main loop
|
|
||||||
send_shutdown = True
|
send_shutdown = True
|
||||||
if args.message:
|
if args.message:
|
||||||
boot_msg["service"] = "service"
|
boot_msg["service"] = "service"
|
||||||
@@ -792,7 +809,10 @@ def main(argv=None):
|
|||||||
# Handle restart
|
# Handle restart
|
||||||
if dorestart:
|
if dorestart:
|
||||||
logging.info("Restarting...")
|
logging.info("Restarting...")
|
||||||
os.execv(sys.argv[0], sys.argv)
|
# Drop -b/--boot so the re-exec'd process doesn't re-announce a boot;
|
||||||
|
# a SIGHUP restart is not a host reboot.
|
||||||
|
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
|
||||||
|
os.execv(restart_argv[0], restart_argv)
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
try:
|
try:
|
||||||
# Maximum filename length
|
# Maximum filename length
|
||||||
max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX')
|
max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX')
|
||||||
if max_name:
|
if max_name is not None:
|
||||||
fs_info['maxfile'] = max_name
|
fs_info['maxfile'] = max_name
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -135,7 +135,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
try:
|
try:
|
||||||
# Maximum path length
|
# Maximum path length
|
||||||
max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX')
|
max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX')
|
||||||
if max_path:
|
if max_path is not None:
|
||||||
fs_info['maxpath'] = max_path
|
fs_info['maxpath'] = max_path
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ SERVER_DEFAULTS = {
|
|||||||
"grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
|
"grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
|
||||||
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
||||||
|
|
||||||
|
# Flap detection (0 in either key disables it)
|
||||||
|
"flap_count": 5, # Warning/critical notifications within flap_interval that trip flapping
|
||||||
|
"flap_interval": 10, # Minutes: the counting window, and the quiet window after an OK
|
||||||
|
|
||||||
# User management
|
# User management
|
||||||
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
|
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
|
||||||
"default_owner": None, # Username that owns hosts with no explicit owner
|
"default_owner": None, # Username that owns hosts with no explicit owner
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Ownership rules for config-file entities (hosts, channels, threshold configs).
|
||||||
|
|
||||||
|
Rule: an entry whose config dict has a non-empty ``owner`` is private to that
|
||||||
|
owner — visible, usable, and editable only by the owner (and admins). An
|
||||||
|
entry with no ``owner`` is global: usable by everyone, editable by admins only.
|
||||||
|
|
||||||
|
The scoped-merge helpers implement the non-admin save path for
|
||||||
|
``POST /api/0/config``: the caller's visible subset of a section is replaced
|
||||||
|
by the submitted payload; everything else is preserved untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedMergeError(Exception):
|
||||||
|
"""A non-admin payload violated an ownership rule; message names the entry."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_global(cfg: Any) -> bool:
|
||||||
|
"""True when *cfg* has no owner (usable by everyone)."""
|
||||||
|
return not (isinstance(cfg, dict) and cfg.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_use(cfg: Any, username: str) -> bool:
|
||||||
|
"""True when *username* may use/see this entry (global or own)."""
|
||||||
|
return is_global(cfg) or cfg.get("owner") == username
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def user_hosts(hosts_cfg: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of *hosts_cfg* where *username* is owner or manager."""
|
||||||
|
result = {}
|
||||||
|
for name, cfg in (hosts_cfg or {}).items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
if cfg.get("owner") == username or username in _as_list(cfg.get("managers")):
|
||||||
|
result[name] = cfg
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def user_channels(channels_cfg: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of channels usable by *username*: global + own."""
|
||||||
|
return {
|
||||||
|
name: cfg for name, cfg in (channels_cfg or {}).items()
|
||||||
|
if isinstance(cfg, dict) and user_can_use(cfg, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_threshold_configs(threshold_cfgs: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of threshold configs usable by *username*: global + own."""
|
||||||
|
return {
|
||||||
|
name: cfg for name, cfg in (threshold_cfgs or {}).items()
|
||||||
|
if isinstance(cfg, dict) and user_can_use(cfg, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_HOST_SETTING_KEYS = ("watch", "dyndns", "notification_channels", "threshold_config")
|
||||||
|
_HOST_ACCESS_KEYS = ("owner", "managers", "monitors")
|
||||||
|
_HOST_ALLOWED_KEYS = frozenset(_HOST_SETTING_KEYS) | frozenset(_HOST_ACCESS_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_added_assignments(host_name: str, old_cfg: Any, new_cfg: dict,
|
||||||
|
key: str, usable: set) -> None:
|
||||||
|
"""Every value under *key* not already on the host must be in *usable*."""
|
||||||
|
old_vals = set(_as_list((old_cfg or {}).get(key)))
|
||||||
|
for v in _as_list(new_cfg.get(key)):
|
||||||
|
if v not in old_vals and v not in usable:
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {host_name!r}: {key} entry {v!r} is not available to you")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_hosts_scoped(existing: Any, payload: Any, username: str,
|
||||||
|
channels_cfg: Any, threshold_cfgs: Any) -> Dict[str, Any]:
|
||||||
|
"""Return a new hosts section with *username*'s visible subset replaced by *payload*.
|
||||||
|
|
||||||
|
Hosts the user cannot see are preserved untouched. Visible hosts missing
|
||||||
|
from the payload are deleted (owners only). Managers may change setting
|
||||||
|
keys but not access keys and may not delete. New hosts get their owner
|
||||||
|
forced to *username*. Newly added channel/threshold assignments must be
|
||||||
|
global or owned by the user. Violations raise ScopedMergeError.
|
||||||
|
"""
|
||||||
|
existing = existing or {}
|
||||||
|
payload = payload or {}
|
||||||
|
visible = user_hosts(existing, username)
|
||||||
|
usable_channels = set(user_channels(channels_cfg, username))
|
||||||
|
usable_tcs = set(user_threshold_configs(threshold_cfgs, username)) | {"default"}
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {n: c for n, c in existing.items() if n not in visible}
|
||||||
|
|
||||||
|
for name, entry in payload.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ScopedMergeError(f"host {name!r}: invalid entry")
|
||||||
|
unknown = set(entry) - _HOST_ALLOWED_KEYS
|
||||||
|
if unknown:
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: fields not permitted: {', '.join(sorted(unknown))}")
|
||||||
|
if name in existing and name not in visible:
|
||||||
|
raise ScopedMergeError(f"host {name!r}: you are not an owner or manager")
|
||||||
|
|
||||||
|
old = visible.get(name)
|
||||||
|
if old is None:
|
||||||
|
new_cfg = dict(entry)
|
||||||
|
new_cfg["owner"] = username
|
||||||
|
else:
|
||||||
|
new_cfg = dict(old)
|
||||||
|
for key in _HOST_SETTING_KEYS:
|
||||||
|
if key in entry:
|
||||||
|
new_cfg[key] = entry[key]
|
||||||
|
else:
|
||||||
|
new_cfg.pop(key, None)
|
||||||
|
if old.get("owner") == username:
|
||||||
|
for key in _HOST_ACCESS_KEYS:
|
||||||
|
if key in entry:
|
||||||
|
new_cfg[key] = entry[key]
|
||||||
|
else:
|
||||||
|
new_cfg.pop(key, None)
|
||||||
|
else:
|
||||||
|
if "owner" in entry and (entry.get("owner") or None) != (old.get("owner") or None):
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: only the owner may change ownership")
|
||||||
|
for key in ("managers", "monitors"):
|
||||||
|
if key in entry and set(_as_list(entry[key])) != set(_as_list(old.get(key))):
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: only the owner may change {key}")
|
||||||
|
|
||||||
|
_check_added_assignments(name, old, new_cfg, "notification_channels", usable_channels)
|
||||||
|
_check_added_assignments(name, old, new_cfg, "threshold_config", usable_tcs)
|
||||||
|
result[name] = new_cfg
|
||||||
|
|
||||||
|
for name, old in visible.items():
|
||||||
|
if name not in payload and old.get("owner") != username:
|
||||||
|
raise ScopedMergeError(f"host {name!r}: only the owner may delete a host")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_threshold_configs_scoped(existing: Any, payload: Any,
|
||||||
|
username: str) -> Dict[str, Any]:
|
||||||
|
"""Return a new threshold_configs section with the user's own configs
|
||||||
|
replaced by *payload*.
|
||||||
|
|
||||||
|
Global and foreign-owned configs are preserved and may not appear in the
|
||||||
|
payload ('default' included). Own configs missing from the payload are
|
||||||
|
deleted. Every payload entry gets its owner forced to *username*.
|
||||||
|
"""
|
||||||
|
existing = existing or {}
|
||||||
|
payload = payload or {}
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
n: c for n, c in existing.items()
|
||||||
|
if not (isinstance(c, dict) and c.get("owner") == username)
|
||||||
|
}
|
||||||
|
for name, entry in payload.items():
|
||||||
|
if name == "default":
|
||||||
|
raise ScopedMergeError("threshold config 'default' is global and admin-managed")
|
||||||
|
old = existing.get(name)
|
||||||
|
if old is not None and (not isinstance(old, dict) or old.get("owner") != username):
|
||||||
|
raise ScopedMergeError(f"threshold config {name!r}: not owned by you")
|
||||||
|
new_cfg = dict(entry) if isinstance(entry, dict) else {}
|
||||||
|
new_cfg["owner"] = username
|
||||||
|
result[name] = new_cfg
|
||||||
|
return result
|
||||||
@@ -19,6 +19,7 @@ def _make_yaml() -> YAML:
|
|||||||
_SERVER_KEYS = [
|
_SERVER_KEYS = [
|
||||||
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
|
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
|
||||||
"interval", "grace", "base_url", "threshold_renotify_interval",
|
"interval", "grace", "base_url", "threshold_renotify_interval",
|
||||||
|
"flap_count", "flap_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",
|
"default_threshold_config",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Flap detection: silence checks that toggle faster than they are useful.
|
||||||
|
|
||||||
|
A ``(host, service)`` pair is *flapping* once it exceeds ``flap_count``
|
||||||
|
WARNING/CRITICAL notifications within ``flap_interval`` minutes. The
|
||||||
|
notification that trips the threshold is delivered with ``FLAP_MARKER``
|
||||||
|
appended; every notification for that pair afterwards is dropped. The state
|
||||||
|
clears silently ``flap_interval`` minutes after a RECOVER, provided no further
|
||||||
|
WARNING/CRITICAL arrived in the meantime.
|
||||||
|
|
||||||
|
Only outbound notifications are affected — ``notify.eventlog`` keeps recording
|
||||||
|
every event, so the journal and ``/log`` retain the full history of the flap.
|
||||||
|
|
||||||
|
State lives here at module level rather than on ``Host`` so it is never
|
||||||
|
pickled: a restart starts every check with a clean slate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FLAP_MARKER = "Now flapping!! No more messages!"
|
||||||
|
|
||||||
|
# Actions returned by observe()
|
||||||
|
PASS = "pass" # deliver unchanged
|
||||||
|
TRIP = "trip" # deliver with FLAP_MARKER appended
|
||||||
|
SUPPRESS = "suppress" # drop
|
||||||
|
|
||||||
|
_count = 0 # flap_count: alerts within the window needed to trip
|
||||||
|
_window = 0.0 # flap_interval in seconds
|
||||||
|
|
||||||
|
# {(host, service): {"events": [ts, ...], "flapping": bool, "ok_since": float|None}}
|
||||||
|
_state: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def setup(cfg) -> None:
|
||||||
|
"""Read flap_count / flap_interval from *cfg* (also called on reload)."""
|
||||||
|
global _count, _window
|
||||||
|
_count = int(cfg.get("flap_count", 0) or 0)
|
||||||
|
_window = float(cfg.get("flap_interval", 0) or 0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
return _count > 0 and _window > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _label(key) -> str:
|
||||||
|
host, service = key
|
||||||
|
return f"{host}/{service}" if service else host
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep(st: dict, now: float) -> bool:
|
||||||
|
"""Clear the flapping flag once the post-RECOVER quiet window has elapsed."""
|
||||||
|
if st["flapping"] and st["ok_since"] is not None and now - st["ok_since"] >= _window:
|
||||||
|
st["flapping"] = False
|
||||||
|
st["ok_since"] = None
|
||||||
|
st["events"].clear()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def observe(host: str, service: str, level: str) -> str:
|
||||||
|
"""Record a notification for *host*/*service* and return what to do with it.
|
||||||
|
|
||||||
|
Returns PASS, TRIP or SUPPRESS.
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return PASS
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
key = (host or "", service or "")
|
||||||
|
level = (level or "").upper()
|
||||||
|
st = _state.get(key)
|
||||||
|
if st is not None and _sweep(st, now):
|
||||||
|
logger.info("flapping cleared for %s", _label(key))
|
||||||
|
|
||||||
|
if level in ("WARNING", "CRITICAL"):
|
||||||
|
if st is None:
|
||||||
|
st = _state[key] = {"events": [], "flapping": False, "ok_since": None}
|
||||||
|
# An alert inside the quiet window means it never settled.
|
||||||
|
st["ok_since"] = None
|
||||||
|
st["events"] = [t for t in st["events"] if now - t < _window]
|
||||||
|
st["events"].append(now)
|
||||||
|
if st["flapping"]:
|
||||||
|
return SUPPRESS
|
||||||
|
if len(st["events"]) > _count:
|
||||||
|
st["flapping"] = True
|
||||||
|
logger.info(
|
||||||
|
"flapping detected for %s (%d alerts in %.0f min)",
|
||||||
|
_label(key), len(st["events"]), _window / 60,
|
||||||
|
)
|
||||||
|
return TRIP
|
||||||
|
return PASS
|
||||||
|
|
||||||
|
if st is None or not st["flapping"]:
|
||||||
|
return PASS
|
||||||
|
if level == "RECOVER":
|
||||||
|
# Starts the quiet window; the state ends silently when it elapses.
|
||||||
|
st["ok_since"] = now
|
||||||
|
return SUPPRESS
|
||||||
|
|
||||||
|
|
||||||
|
def clear_host(host: str) -> None:
|
||||||
|
"""Discard all flap state for *host* (called when a host is dropped).
|
||||||
|
|
||||||
|
A dropped host may be mid-flap with no RECOVER ever received, in which
|
||||||
|
case ``ok_since`` stays ``None`` and ``_sweep`` can never clear it on its
|
||||||
|
own — the state would otherwise persist forever.
|
||||||
|
"""
|
||||||
|
for key in [k for k in _state if k[0] == host]:
|
||||||
|
del _state[key]
|
||||||
|
|
||||||
|
|
||||||
|
def flapping_services(host: str) -> list:
|
||||||
|
"""Return the services of *host* that are currently flapping.
|
||||||
|
|
||||||
|
An empty string in the result means the host itself (connectivity, boot,
|
||||||
|
shutdown) rather than a named service.
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return []
|
||||||
|
now = time.time()
|
||||||
|
flapping = []
|
||||||
|
for (h, service), st in _state.items():
|
||||||
|
if h != host:
|
||||||
|
continue
|
||||||
|
_sweep(st, now)
|
||||||
|
if st["flapping"]:
|
||||||
|
flapping.append(service)
|
||||||
|
return sorted(flapping)
|
||||||
+13
-1
@@ -304,6 +304,14 @@ class Host:
|
|||||||
self.managers: list = [] # usernames with manager role
|
self.managers: list = [] # usernames with manager role
|
||||||
self.monitors: list = [] # usernames with monitor role
|
self.monitors: list = [] # usernames with monitor role
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
"""Prepare Host for pickling by excluding non-serializable timer objects."""
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
# asyncio TimerHandles (and their lambda callbacks) can't be pickled.
|
||||||
|
# They're recreated when the next PLG arrives after unpickling.
|
||||||
|
state['plugin_timers'] = {}
|
||||||
|
return state
|
||||||
|
|
||||||
def statedict(self):
|
def statedict(self):
|
||||||
d = {}
|
d = {}
|
||||||
d["raw_name"] = self.name
|
d["raw_name"] = self.name
|
||||||
@@ -367,7 +375,7 @@ class Host:
|
|||||||
def stateinfo(self):
|
def stateinfo(self):
|
||||||
ddict = {}
|
ddict = {}
|
||||||
for d in self.__dict__:
|
for d in self.__dict__:
|
||||||
if d in ["alert_states", "plugin_data"]:
|
if d in ["alert_states", "plugin_data", "plugin_timers"]:
|
||||||
continue
|
continue
|
||||||
if d == "connections":
|
if d == "connections":
|
||||||
cl = []
|
cl = []
|
||||||
@@ -420,6 +428,10 @@ class Host:
|
|||||||
ddict["alert_critical_unacked"] = critical_unacked
|
ddict["alert_critical_unacked"] = critical_unacked
|
||||||
ddict["alert_critical_acked"] = critical_acked
|
ddict["alert_critical_acked"] = critical_acked
|
||||||
|
|
||||||
|
# Flap detection state (module-level in flap.py, never pickled)
|
||||||
|
from . import flap
|
||||||
|
ddict["flapping"] = flap.flapping_services(self.name)
|
||||||
|
|
||||||
# User access
|
# User access
|
||||||
ddict["owner"] = getattr(self, "owner", None)
|
ddict["owner"] = getattr(self, "owner", None)
|
||||||
ddict["managers"] = list(getattr(self, "managers", []))
|
ddict["managers"] = list(getattr(self, "managers", []))
|
||||||
|
|||||||
+186
-46
@@ -14,12 +14,15 @@ import logging
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import jinja2
|
import jinja2
|
||||||
from . import data
|
from . import data
|
||||||
|
from . import flap as flap_mod
|
||||||
from . import notify as notify_mod
|
from . import notify as notify_mod
|
||||||
from . import settings as settings_mod
|
from . import settings as settings_mod
|
||||||
from . import users as users_mod
|
from . import users as users_mod
|
||||||
from . import oauth as oauth_mod
|
from . import oauth as oauth_mod
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
from . import configio as configio_mod
|
from . import configio as configio_mod
|
||||||
|
from . import journal as journal_mod
|
||||||
|
from . import config_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,19 +30,25 @@ eventlog = notify_mod.eventlog
|
|||||||
|
|
||||||
|
|
||||||
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
||||||
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
|
"""Convert form-submitted threshold data to the nested threshold_configs structure.
|
||||||
|
|
||||||
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
|
Input: {config_name: {owner?: str, metrics: {metric_path: {warning, critical, ...}}}}
|
||||||
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
|
Output: {config_name: {owner?: str, thresholds: {plugin: {metric: {...}}}}}
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
for config_name, metrics in form_data.items():
|
for config_name, cfg in form_data.items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
metrics = cfg.get("metrics")
|
||||||
if not isinstance(metrics, dict):
|
if not isinstance(metrics, dict):
|
||||||
continue
|
continue
|
||||||
thresholds = {}
|
thresholds: dict = {}
|
||||||
for metric_path, values in metrics.items():
|
for metric_path, values in metrics.items():
|
||||||
_insert_threshold_metric(thresholds, metric_path, values)
|
_insert_threshold_metric(thresholds, metric_path, values)
|
||||||
result[config_name] = {"thresholds": thresholds}
|
entry = {"thresholds": thresholds}
|
||||||
|
if cfg.get("owner"):
|
||||||
|
entry["owner"] = cfg["owner"]
|
||||||
|
result[config_name] = entry
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +210,16 @@ def _mask_config_for_api(config) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_plugin_names(plugin_data: dict) -> list:
|
||||||
|
"""Plugin names to render as accordion sections.
|
||||||
|
|
||||||
|
Excludes synthetic rtt_ipv4 / rtt_ipv6 history streams (written by
|
||||||
|
udp.py for charting) which aren't real client plugins and have no
|
||||||
|
accordion renderer.
|
||||||
|
"""
|
||||||
|
return [p for p in plugin_data.keys() if not p.startswith("rtt_")]
|
||||||
|
|
||||||
|
|
||||||
def _build_host_info(host, threshold_checker=None) -> dict:
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
hbc_version = None
|
hbc_version = None
|
||||||
@@ -251,12 +270,25 @@ def _build_host_info(host, threshold_checker=None) -> dict:
|
|||||||
key=lambda x: x["metric"],
|
key=lambda x: x["metric"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
connections = [
|
||||||
|
{
|
||||||
|
"family": getattr(conn, "afam", family),
|
||||||
|
"addr": getattr(conn, "addr", ""),
|
||||||
|
"state": getattr(conn, "state", ""),
|
||||||
|
"rtt": (getattr(conn, "rtts", None) or [None])[-1],
|
||||||
|
"statetime": getattr(conn, "statetime", None),
|
||||||
|
"lastbeat": getattr(conn, "lastbeat", None),
|
||||||
|
}
|
||||||
|
for family, conn in sorted(host.connections.items())
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"owner": getattr(host, "owner", None),
|
"owner": getattr(host, "owner", None),
|
||||||
"managers": list(getattr(host, "managers", [])),
|
"managers": list(getattr(host, "managers", [])),
|
||||||
"hbc_version": hbc_version,
|
"hbc_version": hbc_version,
|
||||||
"hbc_type": hbc_type,
|
"hbc_type": hbc_type,
|
||||||
"last_packet": last_packet,
|
"last_packet": last_packet,
|
||||||
|
"connections": connections,
|
||||||
"thresholds": thresholds,
|
"thresholds": thresholds,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +374,50 @@ async def start(
|
|||||||
lst = data.msgs[-30:]
|
lst = data.msgs[-30:]
|
||||||
return web.json_response(lst)
|
return web.json_response(lst)
|
||||||
|
|
||||||
|
async def api_log(request):
|
||||||
|
"""Paged, filtered read of the events journal (newest first)."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
qa = request.rel_url.query
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(qa.get("limit", "100")), 1000))
|
||||||
|
except ValueError:
|
||||||
|
limit = 100
|
||||||
|
before = None
|
||||||
|
if qa.get("before"):
|
||||||
|
try:
|
||||||
|
before = float(qa["before"])
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "invalid before"}, status=400)
|
||||||
|
host_f = qa.get("host") or None
|
||||||
|
level_f = qa.get("level") or None
|
||||||
|
q_f = qa.get("q") or None
|
||||||
|
|
||||||
|
def visible(ev):
|
||||||
|
h = ev.get("host")
|
||||||
|
return not h or ws_mod._user_can_see_host(user, h)
|
||||||
|
|
||||||
|
ej = journal_mod.get_events_journal(config)
|
||||||
|
if ej.enabled and ej.journal_path.is_file():
|
||||||
|
# File scan can be large; keep it off the loop that services UDP/WS
|
||||||
|
events, more = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: journal_mod.read_events(
|
||||||
|
ej.journal_dir, ej.journal_file,
|
||||||
|
limit=limit, before=before, host=host_f, level=level_f, q=q_f,
|
||||||
|
predicate=visible,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Journal disabled or not yet written: serve the in-memory ring
|
||||||
|
events, more = journal_mod.filter_events(
|
||||||
|
reversed(data.msgs),
|
||||||
|
limit=limit, before=before, host=host_f, level=level_f, q=q_f,
|
||||||
|
predicate=visible,
|
||||||
|
)
|
||||||
|
return web.json_response({"events": events, "more": more})
|
||||||
|
|
||||||
async def cmd(request):
|
async def cmd(request):
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
if err:
|
if err:
|
||||||
@@ -374,6 +450,7 @@ async def start(
|
|||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
eventlog(uname, "INFO", "dropped")
|
eventlog(uname, "INFO", "dropped")
|
||||||
del hbdclass.Host.hosts[uname]
|
del hbdclass.Host.hosts[uname]
|
||||||
|
flap_mod.clear_host(uname)
|
||||||
return web.Response(text="Done")
|
return web.Response(text="Done")
|
||||||
|
|
||||||
async def register(request):
|
async def register(request):
|
||||||
@@ -424,7 +501,7 @@ async def start(
|
|||||||
# Resolve templates directory relative to the hbd package
|
# Resolve templates directory relative to the hbd package
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
host = config.get("hb_host", "localhost")
|
host = config.get("hb_host", "localhost")
|
||||||
extra_scripts = config.get("http_extra_scripts", "")
|
extra_scripts = config.get("http_extra_scripts", "")
|
||||||
host = request.host # includes port if non-standard
|
host = request.host # includes port if non-standard
|
||||||
@@ -446,7 +523,6 @@ async def start(
|
|||||||
for h in sorted(hbdclass.Host.hosts)
|
for h in sorted(hbdclass.Host.hosts)
|
||||||
if _can_operate_host(current_user, hbdclass.Host.hosts[h])
|
if _can_operate_host(current_user, hbdclass.Host.hosts[h])
|
||||||
],
|
],
|
||||||
messages=data.msgs[-30:],
|
|
||||||
current_user=current_user.to_dict() if current_user else None,
|
current_user=current_user.to_dict() if current_user else None,
|
||||||
active_page="live",
|
active_page="live",
|
||||||
)
|
)
|
||||||
@@ -597,8 +673,6 @@ async def start(
|
|||||||
all_alerts = []
|
all_alerts = []
|
||||||
|
|
||||||
for hostname, host in hbdclass.Host.hosts.items():
|
for hostname, host in hbdclass.Host.hosts.items():
|
||||||
if not host.watched:
|
|
||||||
continue
|
|
||||||
if not _can_view_host(user, host):
|
if not _can_view_host(user, host):
|
||||||
continue
|
continue
|
||||||
if threshold_checker:
|
if threshold_checker:
|
||||||
@@ -692,7 +766,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
# Collect all hosts with plugin data (filtered by visibility)
|
# Collect all hosts with plugin data (filtered by visibility)
|
||||||
hosts_with_plugins = []
|
hosts_with_plugins = []
|
||||||
@@ -703,7 +777,7 @@ async def start(
|
|||||||
if host.plugin_data:
|
if host.plugin_data:
|
||||||
hosts_with_plugins.append({
|
hosts_with_plugins.append({
|
||||||
"name": hostname,
|
"name": hostname,
|
||||||
"plugins": list(host.plugin_data.keys()),
|
"plugins": _visible_plugin_names(host.plugin_data),
|
||||||
"is_owner": _can_own_host(current_user, host),
|
"is_owner": _can_own_host(current_user, host),
|
||||||
"owner": host.owner,
|
"owner": host.owner,
|
||||||
})
|
})
|
||||||
@@ -723,7 +797,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
tmpl = env.get_template("alerts.html")
|
tmpl = env.get_template("alerts.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
@@ -734,6 +808,27 @@ async def start(
|
|||||||
)
|
)
|
||||||
return web.Response(text=body, content_type="text/html")
|
return web.Response(text=body, content_type="text/html")
|
||||||
|
|
||||||
|
async def log_page(request):
|
||||||
|
"""Render the event log page."""
|
||||||
|
current_user, _ = _require_auth_redirect(request)
|
||||||
|
pkg_dir = os.path.dirname(__file__)
|
||||||
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
host = request.host # includes port if non-standard
|
||||||
|
forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
|
||||||
|
is_secure = request.secure or forwarded_proto.lower() == "https"
|
||||||
|
scheme = "wss" if is_secure else "ws"
|
||||||
|
heartbeat_ws_url = f"{scheme}://{host}/ws"
|
||||||
|
tmpl = env.get_template("log.html")
|
||||||
|
body = tmpl.render(
|
||||||
|
title="Log - Heartbeat",
|
||||||
|
header="Log",
|
||||||
|
heartbeat_ws_url=heartbeat_ws_url,
|
||||||
|
current_user=current_user.to_dict() if current_user else None,
|
||||||
|
active_page="log",
|
||||||
|
)
|
||||||
|
return web.Response(text=body, content_type="text/html")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Auth endpoints
|
# Auth endpoints
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -780,6 +875,8 @@ async def start(
|
|||||||
token = users_mod.create_session(username)
|
token = users_mod.create_session(username)
|
||||||
eventlog("hbd", "INFO", f"Login: {username} via password")
|
eventlog("hbd", "INFO", f"Login: {username} via password")
|
||||||
redirect_to = request.rel_url.query.get("next", "/")
|
redirect_to = request.rel_url.query.get("next", "/")
|
||||||
|
if not redirect_to.startswith("/"):
|
||||||
|
redirect_to = "/"
|
||||||
resp = web.HTTPFound(redirect_to)
|
resp = web.HTTPFound(redirect_to)
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -891,6 +988,13 @@ async def start(
|
|||||||
if not target_user.avatar_is_local():
|
if not target_user.avatar_is_local():
|
||||||
return web.Response(status=404, text="No local avatar configured")
|
return web.Response(status=404, text="No local avatar configured")
|
||||||
path = target_user.avatar
|
path = target_user.avatar
|
||||||
|
avatar_dir = config.get("avatar_dir") or (
|
||||||
|
os.path.dirname(os.path.realpath(_config_path)) if _config_path else None
|
||||||
|
)
|
||||||
|
if not avatar_dir:
|
||||||
|
return web.Response(status=403, text="Local avatars not configured")
|
||||||
|
if not os.path.realpath(path).startswith(os.path.realpath(avatar_dir) + os.sep):
|
||||||
|
return web.Response(status=403, text="Forbidden")
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
return web.Response(status=404, text="Avatar file not found")
|
return web.Response(status=404, text="Avatar file not found")
|
||||||
# Infer content-type from extension
|
# Infer content-type from extension
|
||||||
@@ -994,7 +1098,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
# Build host access summary for this user.
|
# Build host access summary for this user.
|
||||||
# Merge live hosts with config-only hosts (not yet seen) so the profile
|
# Merge live hosts with config-only hosts (not yet seen) so the profile
|
||||||
@@ -1044,7 +1148,7 @@ async def start(
|
|||||||
"name": name,
|
"name": name,
|
||||||
"type": cfg.get("type", ""),
|
"type": cfg.get("type", ""),
|
||||||
"owner": cfg.get("owner"),
|
"owner": cfg.get("owner"),
|
||||||
"private": bool(cfg.get("private", False)),
|
"private": not config_access.is_global(cfg),
|
||||||
}
|
}
|
||||||
for name, cfg in visible_channels.items()
|
for name, cfg in visible_channels.items()
|
||||||
if isinstance(cfg, dict)
|
if isinstance(cfg, dict)
|
||||||
@@ -1078,7 +1182,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
from hbd import __version__ as hbd_version
|
from hbd import __version__ as hbd_version
|
||||||
|
|
||||||
uptime_secs = int(time.time() - _start_epoch)
|
uptime_secs = int(time.time() - _start_epoch)
|
||||||
@@ -1112,19 +1216,18 @@ async def start(
|
|||||||
return web.Response(text=body, content_type="text/html")
|
return web.Response(text=body, content_type="text/html")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Settings page (admin only)
|
# Settings page
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def settings_page(request):
|
async def settings_page(request):
|
||||||
"""GET /settings — read-only view of the current server configuration."""
|
"""GET /settings — server configuration; non-admins see only what they own or manage."""
|
||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
if current_user and not current_user.admin:
|
|
||||||
raise web.HTTPForbidden(reason="Admin access required")
|
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
tmpl = env.get_template("settings.html")
|
tmpl = env.get_template("settings.html")
|
||||||
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
|
settings_data = settings_mod.get_settings_data(
|
||||||
|
config, threshold_checker=threshold_checker, user=current_user)
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
title="Settings - Heartbeat",
|
title="Settings - Heartbeat",
|
||||||
sections=settings_data["sections"],
|
sections=settings_data["sections"],
|
||||||
@@ -1274,12 +1377,16 @@ async def start(
|
|||||||
return web.json_response({"backups": backups})
|
return web.json_response({"backups": backups})
|
||||||
|
|
||||||
async def api_config_post(request):
|
async def api_config_post(request):
|
||||||
"""POST /api/0/config — publish staged changes to .hb.yaml. Admin only."""
|
"""POST /api/0/config — publish staged changes to .hb.yaml.
|
||||||
|
|
||||||
|
Admins may write any section. Non-admins may submit only 'hosts' and
|
||||||
|
'thresholds'; their payload is merged into the config scoped to the
|
||||||
|
entries they own or manage (see hbd.server.config_access).
|
||||||
|
"""
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
if user and not user.admin:
|
is_admin = user is None or user.admin
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
|
||||||
if not _config_path:
|
if not _config_path:
|
||||||
return web.json_response({"error": "Config path not available"}, status=503)
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
try:
|
try:
|
||||||
@@ -1290,6 +1397,13 @@ async def start(
|
|||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
extra = set(payload) - {"hosts", "thresholds"}
|
||||||
|
if extra:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Not permitted to edit: {', '.join(sorted(extra))}"},
|
||||||
|
status=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = configio_mod.read_roundtrip(_config_path)
|
data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
|
||||||
@@ -1338,18 +1452,36 @@ async def start(
|
|||||||
if "thresholds" in payload:
|
if "thresholds" in payload:
|
||||||
tc = payload["thresholds"]
|
tc = payload["thresholds"]
|
||||||
if isinstance(tc, str):
|
if isinstance(tc, str):
|
||||||
|
if not is_admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
elif isinstance(tc, dict):
|
elif isinstance(tc, dict):
|
||||||
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
built = _build_threshold_configs_from_form(tc)
|
||||||
|
if is_admin:
|
||||||
|
data["threshold_configs"] = built
|
||||||
|
else:
|
||||||
|
data["threshold_configs"] = config_access.merge_threshold_configs_scoped(
|
||||||
|
data.get("threshold_configs") or {}, built, user.username)
|
||||||
|
|
||||||
if "hosts" in payload:
|
if "hosts" in payload:
|
||||||
h = payload["hosts"]
|
h = payload["hosts"]
|
||||||
if isinstance(h, dict):
|
if isinstance(h, dict):
|
||||||
configio_mod.apply_structured_section(data, "hosts", h)
|
if is_admin:
|
||||||
else:
|
configio_mod.apply_structured_section(data, "hosts", h)
|
||||||
|
else:
|
||||||
|
merged = config_access.merge_hosts_scoped(
|
||||||
|
dict(data.get("hosts") or {}), h, user.username,
|
||||||
|
data.get("notification_channels") or {},
|
||||||
|
data.get("threshold_configs") or {})
|
||||||
|
configio_mod.apply_structured_section(data, "hosts", merged)
|
||||||
|
elif is_admin:
|
||||||
configio_mod.apply_yaml_section(data, "hosts", h)
|
configio_mod.apply_yaml_section(data, "hosts", h)
|
||||||
|
else:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
configio_mod.write_config(_config_path, data)
|
configio_mod.write_config(_config_path, data)
|
||||||
|
except config_access.ScopedMergeError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=403)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Config write failed: %s", exc)
|
logger.error("Config write failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
@@ -1400,19 +1532,13 @@ async def start(
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def _visible_channels_for_user(user):
|
def _visible_channels_for_user(user):
|
||||||
"""Return {name: cfg} of channels visible to user (public + own private)."""
|
"""Return {name: cfg} of channels visible to user (global + own)."""
|
||||||
all_channels = config.get("notification_channels") or {}
|
all_channels = config.get("notification_channels") or {}
|
||||||
if user is None:
|
if user is None:
|
||||||
return {}
|
return {}
|
||||||
if user.admin:
|
if user.admin:
|
||||||
return dict(all_channels)
|
return dict(all_channels)
|
||||||
visible = {}
|
return config_access.user_channels(all_channels, user.username)
|
||||||
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):
|
def _build_channel_response(ch_name, ch_cfg):
|
||||||
"""Serialize a channel config dict for the API response."""
|
"""Serialize a channel config dict for the API response."""
|
||||||
@@ -1436,7 +1562,7 @@ async def start(
|
|||||||
"type": ch_type,
|
"type": ch_type,
|
||||||
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
"owner": ch_cfg.get("owner"),
|
"owner": ch_cfg.get("owner"),
|
||||||
"private": bool(ch_cfg.get("private", False)),
|
"private": not config_access.is_global(ch_cfg),
|
||||||
"min_level": ch_cfg.get("min_level", "WARNING"),
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
}
|
}
|
||||||
@@ -1501,9 +1627,12 @@ async def start(
|
|||||||
|
|
||||||
if body.get("min_level"):
|
if body.get("min_level"):
|
||||||
channel_cfg["min_level"] = body["min_level"]
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
channel_cfg["owner"] = user.username
|
if user.admin:
|
||||||
if body.get("private"):
|
owner = (body.get("owner") or "").strip()
|
||||||
channel_cfg["private"] = True
|
if owner:
|
||||||
|
channel_cfg["owner"] = owner
|
||||||
|
else:
|
||||||
|
channel_cfg["owner"] = user.username
|
||||||
|
|
||||||
try:
|
try:
|
||||||
disk_data = configio_mod.read_roundtrip(_config_path)
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
@@ -1568,12 +1697,12 @@ async def start(
|
|||||||
|
|
||||||
if body.get("min_level"):
|
if body.get("min_level"):
|
||||||
channel_cfg["min_level"] = body["min_level"]
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
if owner is not None:
|
if user.admin:
|
||||||
|
new_owner = (body.get("owner") or "").strip() if "owner" in body else (owner or "")
|
||||||
|
if new_owner:
|
||||||
|
channel_cfg["owner"] = new_owner
|
||||||
|
elif owner is not None:
|
||||||
channel_cfg["owner"] = owner
|
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.apply_channel(disk_data, ch_name, channel_cfg)
|
||||||
configio_mod.write_config(_config_path, disk_data)
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
@@ -1661,7 +1790,16 @@ async def start(
|
|||||||
if "full_name" in body:
|
if "full_name" in body:
|
||||||
user_entry["full_name"] = str(body["full_name"])
|
user_entry["full_name"] = str(body["full_name"])
|
||||||
if "avatar" in body:
|
if "avatar" in body:
|
||||||
user_entry["avatar"] = str(body["avatar"])
|
avatar_val = str(body["avatar"])
|
||||||
|
if avatar_val.startswith("/"):
|
||||||
|
avatar_dir = config.get("avatar_dir") or (
|
||||||
|
os.path.dirname(os.path.realpath(_config_path)) if _config_path else None
|
||||||
|
)
|
||||||
|
if not avatar_dir:
|
||||||
|
return web.json_response({"error": "Local avatars not configured"}, status=400)
|
||||||
|
if not os.path.realpath(avatar_val).startswith(os.path.realpath(avatar_dir) + os.sep):
|
||||||
|
return web.json_response({"error": "Avatar path outside allowed directory"}, status=400)
|
||||||
|
user_entry["avatar"] = avatar_val
|
||||||
if "notification_channels" in body:
|
if "notification_channels" in body:
|
||||||
visible = _visible_channels_for_user(user)
|
visible = _visible_channels_for_user(user)
|
||||||
user_entry["notification_channels"] = [
|
user_entry["notification_channels"] = [
|
||||||
@@ -1718,6 +1856,7 @@ async def start(
|
|||||||
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),
|
||||||
web.get("/api/0/messages", api_messages),
|
web.get("/api/0/messages", api_messages),
|
||||||
|
web.get("/api/0/log", api_log),
|
||||||
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins),
|
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins),
|
||||||
web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail),
|
web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail),
|
||||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||||
@@ -1733,6 +1872,7 @@ async def start(
|
|||||||
web.get("/live", live),
|
web.get("/live", live),
|
||||||
web.get("/plugins", plugins_page),
|
web.get("/plugins", plugins_page),
|
||||||
web.get("/alerts", alerts_page),
|
web.get("/alerts", alerts_page),
|
||||||
|
web.get("/log", log_page),
|
||||||
web.get("/about", about_page),
|
web.get("/about", about_page),
|
||||||
web.get("/profile", profile_page),
|
web.get("/profile", profile_page),
|
||||||
web.get("/settings", settings_page),
|
web.get("/settings", settings_page),
|
||||||
|
|||||||
+133
-1
@@ -11,7 +11,7 @@ import os
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional, Callable, Iterable, List, Tuple, Union
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -153,6 +153,31 @@ class MessageJournal:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error writing to journal: {e}")
|
logger.error(f"Error writing to journal: {e}")
|
||||||
|
|
||||||
|
async def log_event(self, event: Dict[str, Any]):
|
||||||
|
"""Write a caller-provided dict verbatim as one JSONL line (with rotation)."""
|
||||||
|
if not self.enabled or not self._initialized:
|
||||||
|
return
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
line = json.dumps(event, separators=(',', ':')) + '\n'
|
||||||
|
nbytes = len(line.encode('utf-8'))
|
||||||
|
if self._current_size + nbytes > self.max_size:
|
||||||
|
await self._rotate()
|
||||||
|
if self._file_handle:
|
||||||
|
self._file_handle.write(line)
|
||||||
|
self._file_handle.flush()
|
||||||
|
self._current_size += nbytes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing event to journal: {e}")
|
||||||
|
|
||||||
|
async def backfill(self, events: Iterable[Dict[str, Any]]):
|
||||||
|
"""One-time seed: write *events* only if the journal file is currently empty."""
|
||||||
|
if not self.enabled or not self._initialized or self._current_size > 0:
|
||||||
|
return
|
||||||
|
for ev in events:
|
||||||
|
if isinstance(ev, dict):
|
||||||
|
await self.log_event(ev)
|
||||||
|
|
||||||
async def _rotate(self):
|
async def _rotate(self):
|
||||||
"""
|
"""
|
||||||
Rotate the journal file.
|
Rotate the journal file.
|
||||||
@@ -309,6 +334,88 @@ class MessageJournal:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_journal_events(journal_dir: Union[str, Path], journal_file: str) -> Iterable[Dict[str, Any]]:
|
||||||
|
"""Yield event dicts from the journal files, newest event first.
|
||||||
|
|
||||||
|
Reads the current file, then rotated backups newest-to-oldest (backup
|
||||||
|
names embed rotation timestamps, so filename sort is chronological).
|
||||||
|
"""
|
||||||
|
dirp = Path(journal_dir)
|
||||||
|
files = [dirp / journal_file]
|
||||||
|
files.extend(sorted(dirp.glob(journal_file + '.*'), reverse=True))
|
||||||
|
for f in files:
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Cannot read journal file {f}: {e}")
|
||||||
|
continue
|
||||||
|
for line in reversed(lines):
|
||||||
|
try:
|
||||||
|
ev = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if isinstance(ev, dict):
|
||||||
|
yield ev
|
||||||
|
|
||||||
|
|
||||||
|
def filter_events(
|
||||||
|
events: Iterable[Dict[str, Any]],
|
||||||
|
limit: int = 100,
|
||||||
|
before: Optional[float] = None,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
|
"""Filter an iterable of event dicts already ordered newest-first.
|
||||||
|
|
||||||
|
Returns (events, more): up to *limit* matching events, and whether at
|
||||||
|
least one further matching event exists beyond the limit.
|
||||||
|
"""
|
||||||
|
host_l = host.lower() if host else None
|
||||||
|
level_l = level.lower() if level else None
|
||||||
|
q_l = q.lower() if q else None
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for ev in events:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
ts = ev.get('ts')
|
||||||
|
if before is not None and (not isinstance(ts, (int, float)) or ts >= before):
|
||||||
|
continue
|
||||||
|
if host_l and host_l not in str(ev.get('host') or '').lower():
|
||||||
|
continue
|
||||||
|
if level_l and str(ev.get('level') or '').lower() != level_l:
|
||||||
|
continue
|
||||||
|
if q_l and q_l not in str(ev.get('message') or '').lower():
|
||||||
|
continue
|
||||||
|
if predicate is not None and not predicate(ev):
|
||||||
|
continue
|
||||||
|
if len(out) >= limit:
|
||||||
|
return out, True
|
||||||
|
out.append(ev)
|
||||||
|
return out, False
|
||||||
|
|
||||||
|
|
||||||
|
def read_events(
|
||||||
|
journal_dir: Union[str, Path],
|
||||||
|
journal_file: str = 'events.journal',
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
before: Optional[float] = None,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
|
"""Read filtered events newest-first from the events journal files."""
|
||||||
|
return filter_events(
|
||||||
|
_iter_journal_events(journal_dir, journal_file),
|
||||||
|
limit=limit, before=before, host=host, level=level, q=q, predicate=predicate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global journal instance
|
# Global journal instance
|
||||||
_journal_instance: Optional[MessageJournal] = None
|
_journal_instance: Optional[MessageJournal] = None
|
||||||
|
|
||||||
@@ -340,3 +447,28 @@ async def log_message(msg: Dict[str, Any], addr: tuple, timestamp: Optional[floa
|
|||||||
"""
|
"""
|
||||||
journal = get_journal()
|
journal = get_journal()
|
||||||
await journal.log_message(msg, addr, timestamp)
|
await journal.log_message(msg, addr, timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
# Global events journal instance (human-readable event log, written by notify.eventlog)
|
||||||
|
_events_journal_instance: Optional[MessageJournal] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_events_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
|
||||||
|
"""Get or create the global events journal instance.
|
||||||
|
|
||||||
|
Uses the events_journal_* config keys; shares journal_dir and
|
||||||
|
journal_enabled with the raw-datagram journal.
|
||||||
|
"""
|
||||||
|
global _events_journal_instance
|
||||||
|
if _events_journal_instance is None:
|
||||||
|
cfg = config or {}
|
||||||
|
_events_journal_instance = MessageJournal(
|
||||||
|
{
|
||||||
|
'journal_dir': cfg.get('journal_dir', '/var/log/heartbeat'),
|
||||||
|
'journal_file': cfg.get('events_journal_file', 'events.journal'),
|
||||||
|
'journal_max_size': cfg.get('events_journal_max_size', 10 * 1024 * 1024),
|
||||||
|
'journal_max_backups': cfg.get('events_journal_max_backups', 10),
|
||||||
|
'journal_enabled': cfg.get('journal_enabled', True),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _events_journal_instance
|
||||||
|
|||||||
+29
-2
@@ -165,6 +165,15 @@ async def _run_async(config, config_path=None):
|
|||||||
msg_journal = journal_mod.get_journal(config)
|
msg_journal = journal_mod.get_journal(config)
|
||||||
await msg_journal.initialize()
|
await msg_journal.initialize()
|
||||||
|
|
||||||
|
# Initialize events journal (human-readable event log for the /log page)
|
||||||
|
events_journal = journal_mod.get_events_journal(config)
|
||||||
|
await events_journal.initialize()
|
||||||
|
if data.msgs:
|
||||||
|
# One-time seed on upgrade: only writes when the journal file is empty
|
||||||
|
await events_journal.backfill(data.msgs)
|
||||||
|
|
||||||
|
notify_mod.eventlog(None, "INFO", f"hbd version {__version__} starting up")
|
||||||
|
|
||||||
# Initialize threshold checker
|
# Initialize threshold checker
|
||||||
threshold_checker = threshold_mod.ThresholdChecker(
|
threshold_checker = threshold_mod.ThresholdChecker(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -379,6 +388,26 @@ async def _run_async(config, config_path=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error closing message journal: %s", e)
|
logger.warning("Error closing message journal: %s", e)
|
||||||
|
|
||||||
|
# Journal the shutdown event now; run()'s shutdown eventlog fires after this journal closes
|
||||||
|
try:
|
||||||
|
await events_journal.log_event(
|
||||||
|
{
|
||||||
|
"ts": time.time(),
|
||||||
|
"host": None,
|
||||||
|
"level": "INFO",
|
||||||
|
"service": None,
|
||||||
|
"message": f"hbd version {__version__} shutdown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not journal shutdown event: %s", e)
|
||||||
|
|
||||||
|
# Close events journal
|
||||||
|
try:
|
||||||
|
await events_journal.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error closing events journal: %s", e)
|
||||||
|
|
||||||
# Signal DNS worker to exit and await it
|
# Signal DNS worker to exit and await it
|
||||||
try:
|
try:
|
||||||
if "dns_task" in locals() and dns_task:
|
if "dns_task" in locals() and dns_task:
|
||||||
@@ -487,8 +516,6 @@ def run(config, config_path=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to write pidfile %s: %s", pidfile, e)
|
logger.warning("Failed to write pidfile %s: %s", pidfile, e)
|
||||||
|
|
||||||
eventlog(None, "INFO", f"hbd version {__version__} starting up")
|
|
||||||
|
|
||||||
if config_path:
|
if config_path:
|
||||||
logger.info(f"Config file: {config_path} (reload with SIGHUP)")
|
logger.info(f"Config file: {config_path} (reload with SIGHUP)")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+39
-1
@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import data
|
from . import data
|
||||||
|
from . import flap as flap_mod
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -33,6 +34,7 @@ msg_to_websockets = ws_mod.broadcast
|
|||||||
|
|
||||||
# Module-level state set via setup()
|
# Module-level state set via setup()
|
||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
# Tracks which channels fired a WARNING/CRITICAL per host.
|
# Tracks which channels fired a WARNING/CRITICAL per host.
|
||||||
# {host_name: set of channel_names} — used to route RECOVER to the same channels.
|
# {host_name: set of channel_names} — used to route RECOVER to the same channels.
|
||||||
@@ -62,6 +64,7 @@ class Notification:
|
|||||||
body: str # detail message
|
body: str # detail message
|
||||||
level: str # RECOVER | WARNING | CRITICAL | INFO
|
level: str # RECOVER | WARNING | CRITICAL | INFO
|
||||||
url: str = "" # link to plugin metrics page
|
url: str = "" # link to plugin metrics page
|
||||||
|
service: str = "" # flap-detection key within the host ("" = the host itself)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -70,14 +73,18 @@ class Notification:
|
|||||||
|
|
||||||
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
|
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
|
||||||
"""Initialize notifier from configuration dict."""
|
"""Initialize notifier from configuration dict."""
|
||||||
global _config
|
global _config, _loop
|
||||||
_config = dict(cfg)
|
_config = dict(cfg)
|
||||||
|
flap_mod.setup(_config)
|
||||||
|
if loop is not None:
|
||||||
|
_loop = loop
|
||||||
|
|
||||||
|
|
||||||
def reload_config(cfg: dict):
|
def reload_config(cfg: dict):
|
||||||
"""Reload notification configuration on SIGHUP."""
|
"""Reload notification configuration on SIGHUP."""
|
||||||
global _config
|
global _config
|
||||||
_config = dict(cfg)
|
_config = dict(cfg)
|
||||||
|
flap_mod.setup(_config)
|
||||||
logger.info("Notification configuration reloaded")
|
logger.info("Notification configuration reloaded")
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +121,11 @@ def eventlog(host, lvl, m, service=None):
|
|||||||
"message": m,
|
"message": m,
|
||||||
}
|
}
|
||||||
data.msgs.append(msg)
|
data.msgs.append(msg)
|
||||||
|
# Cap the in-memory buffer so it doesn't grow without bound; this list is
|
||||||
|
# replayed to every dashboard client on connect and persisted in the pickle.
|
||||||
|
cap = _config.get("msg_buffer_size", 500)
|
||||||
|
if cap and len(data.msgs) > cap:
|
||||||
|
del data.msgs[:-cap]
|
||||||
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
||||||
if host:
|
if host:
|
||||||
s += f"{host} "
|
s += f"{host} "
|
||||||
@@ -126,6 +138,21 @@ def eventlog(host, lvl, m, service=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("failed to write to logfile: %s", e)
|
logger.warning("failed to write to logfile: %s", e)
|
||||||
msg_to_websockets("message", msg)
|
msg_to_websockets("message", msg)
|
||||||
|
_journal_event(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_event(msg: dict):
|
||||||
|
"""Schedule an async write of *msg* to the events journal (no-op without a loop)."""
|
||||||
|
if _loop is None or not _loop.is_running():
|
||||||
|
return
|
||||||
|
from . import journal as journal_mod
|
||||||
|
ej = journal_mod._events_journal_instance
|
||||||
|
if ej is None or not ej.enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(ej.log_event(msg), _loop)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("failed to schedule events journal write: %s", e)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -416,11 +443,22 @@ async def send_notification(host_name: str, notif: Notification) -> dict:
|
|||||||
notification_channels, and dispatches. Silently does nothing if
|
notification_channels, and dispatches. Silently does nothing if
|
||||||
no users are configured.
|
no users are configured.
|
||||||
|
|
||||||
|
Flap detection runs first: once *host_name*/*notif.service* is flapping the
|
||||||
|
notification is dropped, and the one that trips the state carries
|
||||||
|
``flap.FLAP_MARKER``.
|
||||||
|
|
||||||
Returns a dict of {channel_name: bool} results.
|
Returns a dict of {channel_name: bool} results.
|
||||||
"""
|
"""
|
||||||
from . import users as users_mod
|
from . import users as users_mod
|
||||||
from . import hbdclass
|
from . import hbdclass
|
||||||
|
|
||||||
|
action = flap_mod.observe(host_name, notif.service, notif.level)
|
||||||
|
if action == flap_mod.SUPPRESS:
|
||||||
|
logger.debug("flapping: suppressed %s notification for %s", notif.level, host_name)
|
||||||
|
return {}
|
||||||
|
if action == flap_mod.TRIP:
|
||||||
|
notif.body = f"{notif.body} {flap_mod.FLAP_MARKER}"
|
||||||
|
|
||||||
if not users_mod.users_enabled():
|
if not users_mod.users_enabled():
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
+61
-16
@@ -21,6 +21,8 @@ editable bool Reserved for future use — currently always False
|
|||||||
sensitive bool True when the raw value must never be shown
|
sensitive bool True when the raw value must never be shown
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from . import config_access
|
||||||
|
|
||||||
# Credential field names that should always be masked.
|
# Credential field names that should always be masked.
|
||||||
_SECRET_KEYS = frozenset({
|
_SECRET_KEYS = frozenset({
|
||||||
"password", "token", "user_key", "api_key", "secret",
|
"password", "token", "user_key", "api_key", "secret",
|
||||||
@@ -140,9 +142,14 @@ def _sanitize_channel(name, cfg):
|
|||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
def get_settings_sections(config: dict, threshold_checker=None, user=None) -> list:
|
||||||
"""Return ordered list of setting sections for the settings page.
|
"""Return ordered list of setting sections for the settings page.
|
||||||
|
|
||||||
|
*user* is an object with ``username``/``admin`` attributes, or None for
|
||||||
|
the unauthenticated (admin-equivalent) view. Non-admins get only the
|
||||||
|
channels/hosts/thresholds sections, filtered to global items plus what
|
||||||
|
they own or manage.
|
||||||
|
|
||||||
Each section:
|
Each section:
|
||||||
{
|
{
|
||||||
"title": str,
|
"title": str,
|
||||||
@@ -162,6 +169,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"sensitive": bool,
|
"sensitive": bool,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
is_admin = user is None or getattr(user, "admin", False)
|
||||||
|
username: str = getattr(user, "username", "") or ""
|
||||||
|
|
||||||
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
||||||
raw = config.get(key)
|
raw = config.get(key)
|
||||||
if sensitive:
|
if sensitive:
|
||||||
@@ -200,6 +210,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
|
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
|
||||||
if not isinstance(ch_cfg, dict):
|
if not isinstance(ch_cfg, dict):
|
||||||
continue
|
continue
|
||||||
|
if not is_admin and not config_access.user_can_use(ch_cfg, username):
|
||||||
|
continue
|
||||||
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():
|
||||||
@@ -219,18 +231,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"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"),
|
"owner": ch_cfg.get("owner"),
|
||||||
"private": bool(ch_cfg.get("private", False)),
|
"editable": is_admin or ch_cfg.get("owner") == username,
|
||||||
"min_level": ch_cfg.get("min_level", "WARNING"),
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- Users (show metadata only, never password hashes) ----------------
|
# ---- Users (show metadata only, never password hashes) ----------------
|
||||||
users_list = []
|
users_list = []
|
||||||
for username, attrs in (config.get("users") or {}).items():
|
for uname, attrs in (config.get("users") or {}).items():
|
||||||
if not isinstance(attrs, dict):
|
if not isinstance(attrs, dict):
|
||||||
continue
|
continue
|
||||||
users_list.append({
|
users_list.append({
|
||||||
"username": username,
|
"username": uname,
|
||||||
"full_name": attrs.get("full_name", ""),
|
"full_name": attrs.get("full_name", ""),
|
||||||
"admin": bool(attrs.get("admin", False)),
|
"admin": bool(attrs.get("admin", False)),
|
||||||
"avatar": attrs.get("avatar", ""),
|
"avatar": attrs.get("avatar", ""),
|
||||||
@@ -252,9 +264,14 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
}
|
}
|
||||||
|
|
||||||
threshold_config_list = []
|
threshold_config_list = []
|
||||||
|
raw_threshold_cfgs = config.get("threshold_configs") or {}
|
||||||
if threshold_checker is not None:
|
if threshold_checker is not None:
|
||||||
if threshold_checker.threshold_configs:
|
if threshold_checker.threshold_configs:
|
||||||
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
||||||
|
raw_cfg = raw_threshold_cfgs.get(cfg_name)
|
||||||
|
tc_owner = raw_cfg.get("owner") if isinstance(raw_cfg, dict) else None
|
||||||
|
if not is_admin and tc_owner and tc_owner != username:
|
||||||
|
continue
|
||||||
# For the default config use the merged effective set;
|
# For the default config use the merged effective set;
|
||||||
# for named overrides use only the explicitly defined metrics
|
# for named overrides use only the explicitly defined metrics
|
||||||
# (threshold_raw_configs) so inherited defaults are not repeated.
|
# (threshold_raw_configs) so inherited defaults are not repeated.
|
||||||
@@ -266,25 +283,37 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
[_tc_to_row(tc) for tc in display_metrics.values()],
|
[_tc_to_row(tc) for tc in display_metrics.values()],
|
||||||
key=lambda m: m["metric"],
|
key=lambda m: m["metric"],
|
||||||
)
|
)
|
||||||
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
|
threshold_config_list.append({
|
||||||
|
"name": cfg_name,
|
||||||
|
"metrics": metrics,
|
||||||
|
"owner": tc_owner,
|
||||||
|
"editable": is_admin or (tc_owner is not None and tc_owner == username),
|
||||||
|
})
|
||||||
elif threshold_checker.thresholds:
|
elif threshold_checker.thresholds:
|
||||||
metrics = sorted(
|
metrics = sorted(
|
||||||
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
||||||
key=lambda m: m["metric"],
|
key=lambda m: m["metric"],
|
||||||
)
|
)
|
||||||
threshold_config_list.append({"name": "default", "metrics": metrics})
|
threshold_config_list.append({"name": "default", "metrics": metrics,
|
||||||
|
"owner": None, "editable": is_admin})
|
||||||
|
|
||||||
# ---- Hosts summary ----------------------------------------------------
|
# ---- Hosts summary ----------------------------------------------------
|
||||||
hosts_list = []
|
hosts_list = []
|
||||||
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
|
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
|
||||||
if not isinstance(hcfg, dict):
|
if not isinstance(hcfg, dict):
|
||||||
continue
|
continue
|
||||||
|
managers = hcfg.get("managers", [])
|
||||||
|
if isinstance(managers, str):
|
||||||
|
managers = [managers]
|
||||||
|
if not is_admin and hcfg.get("owner") != username and username not in managers:
|
||||||
|
continue
|
||||||
hosts_list.append({
|
hosts_list.append({
|
||||||
"name": hname,
|
"name": hname,
|
||||||
"watch": bool(hcfg.get("watch", True)),
|
"watch": bool(hcfg.get("watch", True)),
|
||||||
"dyndns": bool(hcfg.get("dyndns", False)),
|
"dyndns": bool(hcfg.get("dyndns", False)),
|
||||||
"owner": hcfg.get("owner", ""),
|
"owner": hcfg.get("owner", ""),
|
||||||
"managers": hcfg.get("managers", []),
|
"is_owner": is_admin or hcfg.get("owner") == username,
|
||||||
|
"managers": managers,
|
||||||
"monitors": hcfg.get("monitors", []),
|
"monitors": hcfg.get("monitors", []),
|
||||||
"threshold_configs": (
|
"threshold_configs": (
|
||||||
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
||||||
@@ -309,7 +338,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"logo": pattrs.get("logo", ""),
|
"logo": pattrs.get("logo", ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
return [
|
sections: list = [
|
||||||
{
|
{
|
||||||
"id": "network",
|
"id": "network",
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -357,6 +386,12 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
|
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
|
||||||
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
||||||
"How often to re-send notifications for ongoing threshold alerts.", editable=True),
|
"How often to re-send notifications for ongoing threshold alerts.", editable=True),
|
||||||
|
field("flap_count", "Flap count", "number",
|
||||||
|
"Warning/critical notifications within the flap interval that mark a "
|
||||||
|
"service as flapping and silence it. 0 disables flap detection.", editable=True),
|
||||||
|
field("flap_interval", "Flap interval", "number",
|
||||||
|
"Minutes: the window flap count is measured over, and how long a service "
|
||||||
|
"must stay OK before flapping ends.", editable=True),
|
||||||
field("autosave_interval", "Autosave interval", "duration",
|
field("autosave_interval", "Autosave interval", "duration",
|
||||||
"How often the server saves its state to disk."),
|
"How often the server saves its state to disk."),
|
||||||
field("base_url", "Base URL", "text",
|
field("base_url", "Base URL", "text",
|
||||||
@@ -483,16 +518,26 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
sections = [s for s in sections if s["id"] in ("channels", "hosts", "thresholds")]
|
||||||
|
for s in sections:
|
||||||
|
s["fields"] = []
|
||||||
|
return sections
|
||||||
|
|
||||||
def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
|
||||||
|
def get_settings_data(config: dict, threshold_checker=None, user=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, user=user)
|
||||||
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
is_admin = user is None or getattr(user, "admin", False)
|
||||||
all_usernames = sorted((config.get("users") or {}).keys())
|
username: str = getattr(user, "username", "") or ""
|
||||||
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
|
channels = config.get("notification_channels") or {}
|
||||||
|
threshold_cfgs = config.get("threshold_configs") or {}
|
||||||
|
if not is_admin:
|
||||||
|
channels = config_access.user_channels(channels, username)
|
||||||
|
threshold_cfgs = config_access.user_threshold_configs(threshold_cfgs, username)
|
||||||
return {
|
return {
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"all_channel_names": all_channel_names,
|
"all_channel_names": sorted(channels.keys()),
|
||||||
"all_usernames": all_usernames,
|
"all_usernames": sorted((config.get("users") or {}).keys()),
|
||||||
"all_threshold_configs": all_threshold_configs,
|
"all_threshold_configs": sorted(threshold_cfgs.keys()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/* hbd-ui.css — shared design system for hbd pages.
|
||||||
|
*
|
||||||
|
* Green-biased neutrals, teal accent, mono identifiers. Pages link this
|
||||||
|
* after static/style.css and build on the tokens + components below.
|
||||||
|
* Introduced with the settings-page redesign; see settings.html for the
|
||||||
|
* fullest use of the row/editor idiom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--st-paper: #f4f6f4;
|
||||||
|
--st-surface: #ffffff;
|
||||||
|
--st-surface-2: #eef1ee;
|
||||||
|
--st-ink: #1d2420;
|
||||||
|
--st-muted: #5f6a63;
|
||||||
|
--st-faint: #8a948d;
|
||||||
|
--st-line: #dde3de;
|
||||||
|
--st-line-soft: #e8ece8;
|
||||||
|
--st-accent: #0e7280;
|
||||||
|
--st-accent-ink: #ffffff;
|
||||||
|
--st-accent-soft: #e0eef0;
|
||||||
|
--st-ok: #3f9c5a;
|
||||||
|
--st-ok-soft: #e2f2e7;
|
||||||
|
--st-warn: #d9982a;
|
||||||
|
--st-warn-soft: #f9efdc;
|
||||||
|
--st-crit: #d64545;
|
||||||
|
--st-crit-soft: #f9e3e3;
|
||||||
|
--st-badge-owner: #ede7f6; --st-badge-owner-ink: #5e35b1;
|
||||||
|
--st-badge-mgr: #e3f2fd; --st-badge-mgr-ink: #1565c0;
|
||||||
|
--st-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--st-paper: #131816;
|
||||||
|
--st-surface: #1b211e;
|
||||||
|
--st-surface-2: #222925;
|
||||||
|
--st-ink: #e4eae6;
|
||||||
|
--st-muted: #9aa69e;
|
||||||
|
--st-faint: #6e7a72;
|
||||||
|
--st-line: #2c342f;
|
||||||
|
--st-line-soft: #252c28;
|
||||||
|
--st-accent: #4cc4d4;
|
||||||
|
--st-accent-ink: #0d2326;
|
||||||
|
--st-accent-soft: #143b40;
|
||||||
|
--st-ok: #5cba77;
|
||||||
|
--st-ok-soft: #1c2f22;
|
||||||
|
--st-warn: #e0ad4e;
|
||||||
|
--st-warn-soft: #33290f;
|
||||||
|
--st-crit: #e06c6c;
|
||||||
|
--st-crit-soft: #3a1a1a;
|
||||||
|
--st-badge-owner: #33294a; --st-badge-owner-ink: #b39ddb;
|
||||||
|
--st-badge-mgr: #1b3350; --st-badge-mgr-ink: #90caf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sticky toolbar under the site nav ── */
|
||||||
|
.st-toolbar {
|
||||||
|
position: sticky; top: var(--nav-h, 48px); z-index: 90;
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
padding: 9px 20px;
|
||||||
|
background: var(--st-surface); border-bottom: 1px solid var(--st-line);
|
||||||
|
}
|
||||||
|
.st-toolbar .brand { font-family: var(--st-mono); font-size: 15px; font-weight: 700; letter-spacing: -.02em; color: var(--st-ink); }
|
||||||
|
.st-toolbar .brand .tld { color: var(--st-accent); }
|
||||||
|
.st-toolbar .hintline { font-size: 12px; color: var(--st-faint); }
|
||||||
|
.st-toolbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ── ECG pulse divider ── */
|
||||||
|
.pulse { display: block; width: 100%; height: 14px; }
|
||||||
|
.pulse polyline { fill: none; stroke: var(--st-accent); stroke-width: 1.5; opacity: .55; }
|
||||||
|
|
||||||
|
/* ── buttons ── */
|
||||||
|
.btn {
|
||||||
|
font: 600 13px/1 inherit; border: 1px solid var(--st-line); border-radius: 6px;
|
||||||
|
background: var(--st-surface); color: var(--st-ink); padding: 7px 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:focus-visible, a:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 2px; }
|
||||||
|
.btn.primary { background: var(--st-accent); border-color: var(--st-accent); color: var(--st-accent-ink); }
|
||||||
|
.btn.quiet { border-color: transparent; background: transparent; color: var(--st-muted); }
|
||||||
|
.btn.small { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.btn.danger { color: var(--st-crit); }
|
||||||
|
.btn:disabled { opacity: .6; cursor: default; }
|
||||||
|
|
||||||
|
/* ── section headers as config keys ── */
|
||||||
|
section.st { margin-top: 26px; scroll-margin-top: calc(var(--bars-h, 94px) + 12px); }
|
||||||
|
.sec-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.sec-head h2 { margin: 0; font-family: var(--st-mono); font-size: 15px; font-weight: 700; color: var(--st-ink); }
|
||||||
|
.sec-head h2 .colon { color: var(--st-accent); }
|
||||||
|
.sec-head .count { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
|
||||||
|
.sec-head .sub { font-size: 12px; color: var(--st-muted); }
|
||||||
|
.sec-head .add { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── record list + row ── */
|
||||||
|
.list { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px; overflow: hidden; }
|
||||||
|
.row {
|
||||||
|
display: grid; grid-template-columns: 190px minmax(0,1fr) auto;
|
||||||
|
gap: 4px 14px; align-items: center;
|
||||||
|
padding: 8px 14px; border-bottom: 1px solid var(--st-line-soft);
|
||||||
|
transition: background .12s ease;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .row { transition: none; } }
|
||||||
|
.row:last-child { border-bottom: none; }
|
||||||
|
.row:hover { background: var(--st-surface-2); }
|
||||||
|
.row .id { font-family: var(--st-mono); font-size: 13px; font-weight: 600; color: var(--st-ink); display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.row .id .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.row .act { display: flex; gap: 4px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||||
|
.dot.ok { background: var(--st-ok); }
|
||||||
|
.dot.warn { background: var(--st-warn); }
|
||||||
|
.dot.crit { background: var(--st-crit); }
|
||||||
|
.dot.off { background: transparent; border: 1.5px solid var(--st-faint); }
|
||||||
|
|
||||||
|
.facts { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; min-width: 0; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
|
||||||
|
padding: 3px 7px; border-radius: 4px;
|
||||||
|
background: var(--st-surface-2); color: var(--st-muted);
|
||||||
|
}
|
||||||
|
.chip.owner { background: var(--st-badge-owner); color: var(--st-badge-owner-ink); }
|
||||||
|
.chip.mgr { background: var(--st-badge-mgr); color: var(--st-badge-mgr-ink); }
|
||||||
|
.chip.level { background: transparent; border: 1px solid var(--st-line); }
|
||||||
|
.chip.private { background: transparent; border: 1px dashed var(--st-badge-owner-ink); color: var(--st-badge-owner-ink); }
|
||||||
|
.chip.ok { background: var(--st-ok-soft); color: var(--st-ok); }
|
||||||
|
.chip.warn { background: var(--st-warn-soft); color: var(--st-warn); }
|
||||||
|
.chip.crit { background: var(--st-crit-soft); color: var(--st-crit); }
|
||||||
|
.chip .k { color: var(--st-faint); }
|
||||||
|
.chip .w { color: var(--st-warn); font-weight: 700; }
|
||||||
|
.chip .c { color: var(--st-crit); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── key/value rows (server groups, about page) ── */
|
||||||
|
.kv .row { grid-template-columns: 230px minmax(0,1fr); padding: 6px 14px; }
|
||||||
|
.group {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 14px 5px; font-family: var(--st-mono); font-size: 11px; color: var(--st-faint);
|
||||||
|
background: var(--st-surface); border-bottom: 1px solid var(--st-line-soft);
|
||||||
|
}
|
||||||
|
.group .gbtn { margin-left: auto; }
|
||||||
|
.kv .val { font-family: var(--st-mono); font-size: 12.5px; color: var(--st-muted); min-width: 0; }
|
||||||
|
.kv .val a { color: var(--st-accent); text-decoration: none; }
|
||||||
|
.kv .val a:hover { text-decoration: underline; }
|
||||||
|
.kv .desc { display: block; font-size: 11px; color: var(--st-faint); margin-top: 2px; }
|
||||||
|
.val-masked { color: var(--st-faint); letter-spacing: 2px; }
|
||||||
|
|
||||||
|
.note { margin: 8px 2px 0; font-size: 12px; color: var(--st-faint); }
|
||||||
|
|
||||||
|
/* ── handheld defaults for rows ── */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.st-toolbar { padding: 8px 10px; }
|
||||||
|
.st-toolbar .hintline { display: none; }
|
||||||
|
.row { grid-template-columns: minmax(0,1fr) auto; padding: 9px 12px; }
|
||||||
|
.row .id { grid-row: 1; grid-column: 1; }
|
||||||
|
.row .act { grid-row: 1; grid-column: 2; }
|
||||||
|
.row .facts { grid-column: 1 / -1; }
|
||||||
|
.kv .row { grid-template-columns: 1fr; }
|
||||||
|
section.st { scroll-margin-top: calc(var(--bars-h, 94px) + 50px); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* hbd-ui.js — shared helpers for pages using hbd-ui.css.
|
||||||
|
*
|
||||||
|
* Keeps sticky toolbars stuck just below the fixed site nav, whose height
|
||||||
|
* varies with viewport width and wrapping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _setStickyOffsets() {
|
||||||
|
const nav = document.querySelector('.nav');
|
||||||
|
const tb = document.querySelector('.st-toolbar');
|
||||||
|
const navH = nav ? nav.offsetHeight : 48;
|
||||||
|
const barsH = navH + (tb ? tb.offsetHeight : 46);
|
||||||
|
document.documentElement.style.setProperty('--nav-h', navH + 'px');
|
||||||
|
document.documentElement.style.setProperty('--bars-h', barsH + 'px');
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', _setStickyOffsets);
|
||||||
|
document.addEventListener('DOMContentLoaded', _setStickyOffsets);
|
||||||
+96
-173
@@ -1,190 +1,113 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
<link rel="stylesheet" href="/static/hbd-ui.css">
|
||||||
|
<script src="/static/hbd-ui.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html, body { overflow: visible; }
|
html, body { height: auto; overflow: visible; }
|
||||||
|
body { background: var(--st-paper); padding: 60px 0 0; }
|
||||||
|
|
||||||
.container {
|
.about-main { max-width: 700px; margin: 0 auto; padding: 0 20px 60px; }
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
.hero {
|
||||||
|
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
|
||||||
|
padding: 26px 2px 0;
|
||||||
}
|
}
|
||||||
|
.hero .hb-logo {
|
||||||
h1 {
|
font-family: var(--st-mono); font-size: 30px; font-weight: 700;
|
||||||
color: #333;
|
letter-spacing: -.03em; color: var(--st-ink);
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
}
|
||||||
|
.hero .hb-logo .tld { color: var(--st-accent); }
|
||||||
.subtitle {
|
.hero .version { font-family: var(--st-mono); font-size: 13px; color: var(--st-accent);
|
||||||
color: #666;
|
background: var(--st-accent-soft); border-radius: 999px; padding: 3px 12px; }
|
||||||
margin-bottom: 24px;
|
.hero .tagline { flex-basis: 100%; font-size: 13px; color: var(--st-muted); }
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
|
||||||
padding: 20px 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h2 {
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid #f5f5f5;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.info-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
width: 160px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.88em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
color: #222;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value a {
|
|
||||||
color: #0066cc;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.info-value a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.version-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 12px;
|
|
||||||
background: #e8f0fe;
|
|
||||||
color: #1a73e8;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1.00em;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hb-logo {
|
|
||||||
font-size: 2.5em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0066cc;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hb-tagline {
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 8px 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text { flex: 1; }
|
|
||||||
|
|
||||||
/* ── Dark mode ── */
|
|
||||||
html[data-theme="dark"] h1 { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
|
|
||||||
html[data-theme="dark"] .section h2 { color: var(--text); border-bottom-color: var(--border); }
|
|
||||||
html[data-theme="dark"] .info-row { border-bottom-color: var(--border-4); }
|
|
||||||
html[data-theme="dark"] .info-label { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .info-value { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .info-value a { color: var(--link); }
|
|
||||||
html[data-theme="dark"] .hb-logo { color: var(--link); }
|
|
||||||
html[data-theme="dark"] .hb-tagline { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .version-badge { background: #1a3255; color: #60a5fa; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="st-toolbar">
|
||||||
<h1>{{ header }}</h1>
|
<span class="brand">hbd<span class="tld">·about</span></span>
|
||||||
<p class="subtitle">Heartbeat monitoring system</p>
|
<span class="hintline">heartbeat monitoring system</span>
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="logo-section">
|
|
||||||
<div class="logo-text">
|
|
||||||
<div class="hb-logo">Heartbeat</div>
|
|
||||||
<div class="hb-tagline">Lightweight host monitoring over UDP</div>
|
|
||||||
</div>
|
|
||||||
<span class="version-badge">v{{ hbd_version }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Version</h2>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Server version</span>
|
|
||||||
<span class="info-value">{{ hbd_version }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Python</span>
|
|
||||||
<span class="info-value">{{ python_version }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">License</span>
|
|
||||||
<span class="info-value">MIT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Runtime</h2>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Host</span>
|
|
||||||
<span class="info-value">{{ server_hostname }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Started</span>
|
|
||||||
<span class="info-value">{{ start_time_str }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Uptime</span>
|
|
||||||
<span class="info-value" id="uptime-value">{{ uptime_str }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Hosts monitored</span>
|
|
||||||
<span class="info-value">{{ host_count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Contact & Source</h2>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Author</span>
|
|
||||||
<span class="info-value">Andreas Wrede</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span class="info-value"><a href="mailto:aew.hbd@wrede.ca">aew.hbd@wrede.ca</a></span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Repository</span>
|
|
||||||
<span class="info-value"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="about-main">
|
||||||
|
<div class="hero">
|
||||||
|
<span class="hb-logo">heart<span class="tld">beat</span></span>
|
||||||
|
<span class="version">v{{ hbd_version }}</span>
|
||||||
|
<span class="tagline">Lightweight host monitoring over UDP</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="st">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>version<span class="colon">:</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="list kv">
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">server</span></span>
|
||||||
|
<span class="val">{{ hbd_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">python</span></span>
|
||||||
|
<span class="val">{{ python_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">license</span></span>
|
||||||
|
<span class="val">MIT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="st">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>runtime<span class="colon">:</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="list kv">
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">host</span></span>
|
||||||
|
<span class="val">{{ server_hostname }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">started</span></span>
|
||||||
|
<span class="val">{{ start_time_str }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">uptime</span></span>
|
||||||
|
<span class="val" id="uptime-value">{{ uptime_str }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">hosts_monitored</span></span>
|
||||||
|
<span class="val">{{ host_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="st">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>contact<span class="colon">:</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="list kv">
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">author</span></span>
|
||||||
|
<span class="val">Andreas Wrede</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">email</span></span>
|
||||||
|
<span class="val"><a href="mailto:aew.hbd@wrede.ca">aew.hbd@wrede.ca</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="id"><span class="name">repository</span></span>
|
||||||
|
<span class="val"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'foot.html' %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
+132
-483
@@ -1,376 +1,97 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
<link rel="stylesheet" href="/static/hbd-ui.css">
|
||||||
|
<script src="/static/hbd-ui.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
html, body { height: auto; overflow: visible; }
|
||||||
|
body { background: var(--st-paper); padding: 60px 0 0; }
|
||||||
|
|
||||||
html, body {
|
.alerts-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
|
||||||
height: auto;
|
|
||||||
overflow-y: auto;
|
/* summary tiles */
|
||||||
|
.stats { display: flex; gap: 10px; flex-wrap: wrap; padding-top: 18px; }
|
||||||
|
.stat {
|
||||||
|
flex: 1 1 140px; display: flex; align-items: baseline; gap: 10px;
|
||||||
|
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
.stat .num { font-family: var(--st-mono); font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat .lbl { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
|
||||||
|
.stat.crit .num { color: var(--st-crit); }
|
||||||
|
.stat.warn .num { color: var(--st-warn); }
|
||||||
|
.stat.ok .num { color: var(--st-ok); }
|
||||||
|
|
||||||
.container {
|
/* filter bar */
|
||||||
max-width: 1400px;
|
.filterbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 14px 0 0; }
|
||||||
margin: 0 auto;
|
.filterbar .fbtn {
|
||||||
|
font-family: var(--st-mono); font-size: 12px; cursor: pointer;
|
||||||
|
color: var(--st-muted); background: var(--st-surface); border: 1px solid var(--st-line);
|
||||||
|
border-radius: 999px; padding: 5px 12px;
|
||||||
}
|
}
|
||||||
|
.filterbar .fbtn.active { background: var(--st-accent-soft); border-color: var(--st-accent); color: var(--st-accent); font-weight: 600; }
|
||||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
.filterbar input {
|
||||||
|
font: 12px var(--st-mono); color: var(--st-ink);
|
||||||
.subtitle {
|
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
|
||||||
color: #666;
|
padding: 5px 12px; min-width: 170px;
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
|
.filterbar input.invalid { border-color: var(--st-crit); }
|
||||||
|
.filterbar .upd { margin-left: auto; font-size: 11.5px; color: var(--st-faint); }
|
||||||
|
|
||||||
.summary-cards {
|
/* alert row specifics */
|
||||||
display: flex;
|
.row.alert-crit { border-left: 3px solid var(--st-crit); padding-left: 11px; }
|
||||||
flex-wrap: wrap;
|
.row.alert-warn { border-left: 3px solid var(--st-warn); padding-left: 11px; }
|
||||||
gap: 10px;
|
.row.acked { opacity: .55; border-left-style: dashed; }
|
||||||
margin-bottom: 16px;
|
.row .id a { color: inherit; text-decoration: none; }
|
||||||
|
.row .id a:hover { color: var(--st-accent); }
|
||||||
|
.acked-chip { color: var(--st-ok); font-size: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 36px 14px; text-align: center; color: var(--st-faint); font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.empty .big { font-size: 26px; color: var(--st-ok); display: block; margin-bottom: 6px; }
|
||||||
.summary-card {
|
.errorbox {
|
||||||
background: white;
|
background: var(--st-crit-soft); color: var(--st-crit);
|
||||||
border-radius: 6px;
|
border-radius: 8px; padding: 14px 16px; font-size: 13px;
|
||||||
padding: 6px 14px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
border-left: 4px solid #ddd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card.critical { border-left-color: #ea1e0f; }
|
|
||||||
.summary-card.warning { border-left-color: #ff9800; }
|
|
||||||
.summary-card.ok { border-left-color: #4caf50; }
|
|
||||||
|
|
||||||
.summary-number {
|
|
||||||
font-size: 1.4em;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-number.critical { color: #ea1e0f; }
|
|
||||||
.summary-number.warning { color: #ff9800; }
|
|
||||||
.summary-number.ok { color: #4caf50; }
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
color: #666;
|
|
||||||
font-size: 1.00em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
background: white;
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button:hover {
|
|
||||||
border-color: #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button.active {
|
|
||||||
background: #2196f3;
|
|
||||||
color: white;
|
|
||||||
border-color: #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input {
|
|
||||||
padding: 7px 12px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
outline: none;
|
|
||||||
width: 200px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input:focus {
|
|
||||||
border-color: #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input.invalid {
|
|
||||||
border-color: #f44336;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alerts-container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item {
|
|
||||||
border-left: 5px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item.acknowledged {
|
|
||||||
opacity: 0.8;
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item:hover {
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item.critical {
|
|
||||||
border-left-color: #f44336;
|
|
||||||
background: #ffebee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item.warning {
|
|
||||||
border-left-color: #ff9800;
|
|
||||||
background: #fff3e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item.unknown {
|
|
||||||
border-left-color: #9e9e9e;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-level {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-level.critical {
|
|
||||||
background: #f44336;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-level.warning {
|
|
||||||
background: #ff9800;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-level.unknown {
|
|
||||||
background: #9e9e9e;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-hostname {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #0066cc;
|
|
||||||
font-size: 1.1em;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.alert-hostname:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-metric {
|
|
||||||
color: #0066cc;
|
|
||||||
font-size: 1.1em;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-details {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-value {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-duration {
|
|
||||||
color: #999;
|
|
||||||
font-size: 1.00em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acknowledge-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #2196f3;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.00em;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acknowledge-btn:hover {
|
|
||||||
background: #1976d2;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.acknowledge-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acknowledged-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: #4caf50;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-alerts {
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-alerts-icon {
|
|
||||||
font-size: 4em;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #ffebee;
|
|
||||||
border-left: 4px solid #f44336;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-info {
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
font-size: 1.00em;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-update {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9em;
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dark mode ── */
|
|
||||||
html[data-theme="dark"] h1 { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .summary-card { background: var(--surface); }
|
|
||||||
html[data-theme="dark"] .summary-label { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .filters { background: var(--surface); }
|
|
||||||
html[data-theme="dark"] .filter-label { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .filter-button { background: var(--surface-2); border-color: var(--border); color: var(--text); }
|
|
||||||
html[data-theme="dark"] .filter-button.active { background: #2196f3; color: #fff; border-color: #2196f3; }
|
|
||||||
html[data-theme="dark"] .filter-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
|
||||||
html[data-theme="dark"] .alerts-container { background: var(--surface); }
|
|
||||||
html[data-theme="dark"] .alert-item { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .alert-item.acknowledged { background: var(--surface-3); }
|
|
||||||
html[data-theme="dark"] .alert-item.critical { background: #2e0a0a; border-left-color: #f44336; }
|
|
||||||
html[data-theme="dark"] .alert-item.warning { background: #2e1a00; border-left-color: #ff9800; }
|
|
||||||
html[data-theme="dark"] .alert-item.unknown { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .alert-hostname { color: var(--link); }
|
|
||||||
html[data-theme="dark"] .alert-details { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .alert-value { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .alert-duration { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .last-update { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .refresh-info { color: var(--text-muted); border-top-color: var(--border); }
|
|
||||||
html[data-theme="dark"] .no-alerts,
|
|
||||||
html[data-theme="dark"] .loading { color: var(--text-muted); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="st-toolbar">
|
||||||
<h1>{{ header }}</h1>
|
<span class="brand">hbd<span class="tld">·alerts</span></span>
|
||||||
<p class="subtitle">Real-time monitoring alerts and threshold violations</p>
|
<span class="hintline">threshold violations and reachability — refreshes every 15 s</span>
|
||||||
|
</div>
|
||||||
|
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div class="summary-cards" id="summary-cards">
|
<div class="alerts-main">
|
||||||
<div class="summary-card critical">
|
<div class="stats">
|
||||||
<div class="summary-label">Critical</div>
|
<div class="stat crit"><span class="num" id="critical-count">–</span><span class="lbl">critical</span></div>
|
||||||
<div class="summary-number critical" id="critical-count">-</div>
|
<div class="stat warn"><span class="num" id="warning-count">–</span><span class="lbl">warning</span></div>
|
||||||
</div>
|
<div class="stat ok"><span class="num" id="host-count">–</span><span class="lbl">hosts</span></div>
|
||||||
<div class="summary-card warning">
|
|
||||||
<div class="summary-label">Warning</div>
|
|
||||||
<div class="summary-number warning" id="warning-count">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card ok">
|
|
||||||
<div class="summary-label">Total Hosts</div>
|
|
||||||
<div class="summary-number ok" id="host-count">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filterbar">
|
||||||
<span class="filter-label">Show:</span>
|
<button class="fbtn active" onclick="filterAlerts('all', this)">all</button>
|
||||||
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
<button class="fbtn" onclick="filterAlerts('critical', this)">critical</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
<button class="fbtn" onclick="filterAlerts('warning', this)">warning</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
<input id="host-filter" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
||||||
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
<span class="upd">updated <span id="last-update-time">never</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alerts-container">
|
<section class="st" id="alerts">
|
||||||
<div class="last-update">Last updated: <span id="last-update-time">Never</span></div>
|
<div class="sec-head">
|
||||||
<div id="alerts-list">
|
<h2>alerts<span class="colon">:</span></h2>
|
||||||
<div class="loading">Loading alerts...</div>
|
<span class="count" id="alert-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="refresh-info">
|
<div class="list" id="alerts-list">
|
||||||
Auto-refreshing every 15 seconds
|
<div class="empty">Loading alerts…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -378,207 +99,138 @@
|
|||||||
let allAlerts = [];
|
let allAlerts = [];
|
||||||
let hostFilterRe = null;
|
let hostFilterRe = null;
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAlerts() {
|
async function loadAlerts() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/0/alerts');
|
const response = await fetch('/api/0/alerts');
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
allAlerts = data.alerts;
|
allAlerts = data.alerts;
|
||||||
|
|
||||||
// Update summary cards
|
|
||||||
document.getElementById('critical-count').textContent = data.summary.critical || 0;
|
document.getElementById('critical-count').textContent = data.summary.critical || 0;
|
||||||
document.getElementById('warning-count').textContent = data.summary.warning || 0;
|
document.getElementById('warning-count').textContent = data.summary.warning || 0;
|
||||||
document.getElementById('host-count').textContent = data.host_count || 0;
|
document.getElementById('host-count').textContent = data.host_count || 0;
|
||||||
|
|
||||||
// Update last update time
|
|
||||||
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
|
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
// Render alerts
|
|
||||||
renderAlerts(allAlerts);
|
renderAlerts(allAlerts);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('alerts-list').innerHTML =
|
document.getElementById('alerts-list').innerHTML =
|
||||||
`<div class="error">Failed to load alerts: ${error.message}</div>`;
|
`<div class="errorbox">Failed to load alerts: ${escHtml(error.message)}. Retrying automatically.</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAlerts(alerts) {
|
function renderAlerts(alerts) {
|
||||||
const container = document.getElementById('alerts-list');
|
const container = document.getElementById('alerts-list');
|
||||||
|
|
||||||
// Filter alerts based on current filter
|
let filtered = alerts;
|
||||||
let filteredAlerts = alerts;
|
|
||||||
if (currentFilter !== 'all') {
|
if (currentFilter !== 'all') {
|
||||||
filteredAlerts = filteredAlerts.filter(alert =>
|
filtered = filtered.filter(a => a.level.toLowerCase() === currentFilter);
|
||||||
alert.level.toLowerCase() === currentFilter
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (hostFilterRe) {
|
if (hostFilterRe) {
|
||||||
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
|
filtered = filtered.filter(a => hostFilterRe.test(a.hostname));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredAlerts.length === 0) {
|
document.getElementById('alert-count').textContent =
|
||||||
if (currentFilter === 'all' && alerts.length === 0) {
|
filtered.length === alerts.length ? alerts.length : `${filtered.length} of ${alerts.length}`;
|
||||||
container.innerHTML = `
|
|
||||||
<div class="no-alerts">
|
if (filtered.length === 0) {
|
||||||
<div class="no-alerts-icon">✓</div>
|
container.innerHTML = (currentFilter === 'all' && !hostFilterRe && alerts.length === 0)
|
||||||
<h2>All Systems Normal</h2>
|
? `<div class="empty"><span class="big">✓</span>All systems normal — no active alerts.</div>`
|
||||||
<p>No active alerts at this time</p>
|
: `<div class="empty">No matching alerts.</div>`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="no-alerts">
|
|
||||||
<p>No ${currentFilter} alerts</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
container.innerHTML = filtered.map(renderAlert).join('');
|
||||||
for (const alert of filteredAlerts) {
|
|
||||||
html += renderAlert(alert);
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAlert(alert) {
|
function renderAlert(alert) {
|
||||||
const level = alert.level.toLowerCase();
|
const level = alert.level.toLowerCase();
|
||||||
|
const cls = level === 'critical' ? 'crit' : (level === 'warning' ? 'warn' : 'level');
|
||||||
const duration = getDuration(alert.since);
|
const duration = getDuration(alert.since);
|
||||||
const acknowledged = alert.acknowledged || false;
|
const acked = alert.acknowledged || false;
|
||||||
|
|
||||||
// Use formatted message if available, otherwise build from individual fields
|
const metric = (alert.metric_path.includes('.')
|
||||||
let valueText = `Value: <span class="alert-value">${formatValue(alert.last_value)}</span>`;
|
? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1)
|
||||||
|
: alert.metric_path).replace(/_status_code$/, '');
|
||||||
|
|
||||||
|
let chips = `<span class="chip ${cls}">${escHtml(alert.level)}</span>`;
|
||||||
|
chips += `<span class="chip"><span class="k">metric</span> ${escHtml(metric)}</span>`;
|
||||||
if (alert.formatted_message) {
|
if (alert.formatted_message) {
|
||||||
valueText += ` <span class="threshold-info">${alert.formatted_message}</span>`;
|
chips += `<span class="chip">${escHtml(alert.formatted_message)}</span>`;
|
||||||
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
} else {
|
||||||
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
|
chips += `<span class="chip"><span class="k">value</span> ${escHtml(formatValue(alert.last_value))}</span>`;
|
||||||
|
if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
||||||
|
chips += `<span class="chip level">${escHtml(alert.operator)} ${escHtml(formatValue(alert.threshold_value))}</span>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
|
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
|
||||||
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
|
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
|
||||||
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
|
chips += `<span class="chip level">recovers ${recOp} ${escHtml(formatValue(alert.recovery_threshold))}</span>`;
|
||||||
}
|
}
|
||||||
|
chips += `<span class="chip"><span class="k">for</span> ${duration}</span>`;
|
||||||
|
|
||||||
// Build actions section
|
const act = acked
|
||||||
let actionsHtml = '';
|
? `<span class="acked-chip">✓ acknowledged</span>`
|
||||||
if (acknowledged) {
|
: `<button class="btn small" onclick="acknowledgeAlert('${escHtml(alert.hostname)}', '${escHtml(alert.metric_path)}', event)">Acknowledge</button>`;
|
||||||
actionsHtml = `
|
|
||||||
<div class="alert-actions">
|
|
||||||
<div class="acknowledged-badge">✓ Acknowledged</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
actionsHtml = `
|
|
||||||
<div class="alert-actions">
|
|
||||||
<button class="acknowledge-btn" onclick="acknowledgeAlert('${alert.hostname}', '${alert.metric_path}', event)">
|
|
||||||
Acknowledge
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="alert-item ${level} ${acknowledged ? 'acknowledged' : ''}">
|
<div class="row alert-${cls === 'level' ? 'warn' : cls}${acked ? ' acked' : ''}">
|
||||||
<div class="alert-main">
|
<span class="id">
|
||||||
<div class="alert-header">
|
<span class="dot ${cls === 'crit' ? 'crit' : 'warn'}"></span>
|
||||||
<span class="alert-level ${level}">${alert.level}</span>
|
<a class="name" href="/plugins#${encodeURIComponent(alert.hostname)}">${escHtml(alert.hostname)}</a>
|
||||||
<a class="alert-hostname" href="/plugins#${alert.hostname}">${alert.hostname}</a>
|
</span>
|
||||||
<span class="alert-metric">${(alert.metric_path.includes('.') ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1) : alert.metric_path).replace(/_status_code$/, '')}</span>
|
<span class="facts">${chips}</span>
|
||||||
</div>
|
<span class="act">${act}</span>
|
||||||
<div class="alert-details">
|
</div>`;
|
||||||
<span>${valueText}</span>
|
|
||||||
<span class="alert-duration">Active for ${duration}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${actionsHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value) {
|
function formatValue(value) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
if (value > 1000) {
|
if (value > 1000) return value.toLocaleString();
|
||||||
return value.toLocaleString();
|
|
||||||
}
|
|
||||||
return value.toFixed(2);
|
return value.toFixed(2);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDuration(timestamp) {
|
function getDuration(timestamp) {
|
||||||
const now = Date.now() / 1000;
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
const seconds = Math.floor(now - timestamp);
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
if (seconds < 60) {
|
if (seconds < 86400) {
|
||||||
return `${seconds}s`;
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
} else if (seconds < 3600) {
|
|
||||||
return `${Math.floor(seconds / 60)}m`;
|
|
||||||
} else if (seconds < 86400) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
} else {
|
|
||||||
const days = Math.floor(seconds / 86400);
|
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
|
||||||
return `${days}d ${hours}h`;
|
|
||||||
}
|
}
|
||||||
|
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAlerts(filter) {
|
function filterAlerts(filter, btn) {
|
||||||
currentFilter = filter;
|
currentFilter = filter;
|
||||||
|
document.querySelectorAll('.filterbar .fbtn').forEach(b => b.classList.remove('active'));
|
||||||
// Update active button
|
btn.classList.add('active');
|
||||||
document.querySelectorAll('.filter-button').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
event.target.classList.add('active');
|
|
||||||
|
|
||||||
// Re-render with new filter
|
|
||||||
renderAlerts(allAlerts);
|
renderAlerts(allAlerts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acknowledgeAlert(hostname, metricPath, event) {
|
async function acknowledgeAlert(hostname, metricPath, event) {
|
||||||
// Prevent event bubbling
|
if (event) event.stopPropagation();
|
||||||
if (event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable the button
|
|
||||||
const button = event.target;
|
const button = event.target;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Acknowledging...';
|
button.textContent = 'Acknowledging…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/0/alerts/acknowledge', {
|
const response = await fetch('/api/0/alerts/acknowledge', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {'Content-Type': 'application/json'},
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({hostname: hostname, metric_path: metricPath}),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
hostname: hostname,
|
|
||||||
metric_path: metricPath,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const a = allAlerts.find(x => x.hostname === hostname && x.metric_path === metricPath);
|
||||||
// Update the alert in our local data
|
if (a) {
|
||||||
const alert = allAlerts.find(a => a.hostname === hostname && a.metric_path === metricPath);
|
a.acknowledged = true;
|
||||||
if (alert) {
|
a.acknowledged_at = result.acknowledged_at;
|
||||||
alert.acknowledged = true;
|
|
||||||
alert.acknowledged_at = result.acknowledged_at;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render alerts
|
|
||||||
renderAlerts(allAlerts);
|
renderAlerts(allAlerts);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to acknowledge alert: ${error.message}`);
|
alert(`Failed to acknowledge alert: ${error.message}`);
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
@@ -603,10 +255,8 @@
|
|||||||
renderAlerts(allAlerts);
|
renderAlerts(allAlerts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh every 15 seconds
|
|
||||||
setInterval(loadAlerts, 15000);
|
setInterval(loadAlerts, 15000);
|
||||||
|
|
||||||
// Initialise filter from URL query string (?filter=...)
|
|
||||||
(function () {
|
(function () {
|
||||||
const param = new URLSearchParams(window.location.search).get('filter');
|
const param = new URLSearchParams(window.location.search).get('filter');
|
||||||
if (param) {
|
if (param) {
|
||||||
@@ -616,7 +266,6 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Initial load
|
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+382
-667
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% include 'head.html' %}
|
||||||
|
<link rel="stylesheet" href="/static/hbd-ui.css">
|
||||||
|
<script src="/static/hbd-ui.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { height: auto; overflow: visible; }
|
||||||
|
body { background: var(--st-paper); padding: 60px 0 0; }
|
||||||
|
|
||||||
|
.log-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
|
||||||
|
|
||||||
|
.st-toolbar input, .st-toolbar select {
|
||||||
|
font: 12px var(--st-mono); color: var(--st-ink);
|
||||||
|
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.live-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--st-ok); }
|
||||||
|
.live-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--st-ok); }
|
||||||
|
.live-dot.reconnecting { color: var(--st-crit); }
|
||||||
|
.live-dot.reconnecting::before { background: var(--st-crit); }
|
||||||
|
|
||||||
|
.logrow {
|
||||||
|
display: grid; grid-template-columns: 130px 74px 150px minmax(0,1fr);
|
||||||
|
gap: 4px 14px; align-items: baseline;
|
||||||
|
padding: 4px 14px; border-bottom: 1px solid var(--st-line-soft);
|
||||||
|
font-family: var(--st-mono); font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.logrow:last-child { border-bottom: none; }
|
||||||
|
.logrow .t { color: var(--st-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.logrow .h { color: var(--st-ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.logrow .m { color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.empty { padding: 24px 14px; text-align: center; color: var(--st-faint); font-size: 12.5px; }
|
||||||
|
.loadmore {
|
||||||
|
display: block; margin: 14px auto 0; cursor: pointer;
|
||||||
|
font-family: var(--st-mono); font-size: 12px; color: var(--st-muted);
|
||||||
|
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
.loadmore:hover { color: var(--st-accent); border-color: var(--st-accent); }
|
||||||
|
.loadmore:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.logrow { grid-template-columns: 66px minmax(0,1fr); }
|
||||||
|
.logrow .lvlc, .logrow .h { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
|
<div class="st-toolbar">
|
||||||
|
<span class="brand">hbd<span class="tld">·log</span></span>
|
||||||
|
<span class="hintline">connectivity and alert events — full journal history</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<input type="text" id="filter-host" placeholder="host">
|
||||||
|
<select id="filter-level">
|
||||||
|
<option value="">level</option>
|
||||||
|
<option value="info">INFO</option>
|
||||||
|
<option value="warning">WARNING</option>
|
||||||
|
<option value="critical">CRITICAL</option>
|
||||||
|
<option value="recover">RECOVER</option>
|
||||||
|
<option value="unknown">UNKNOWN</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-msg" placeholder="message">
|
||||||
|
<span class="live-dot reconnecting" id="ws-status">connecting…</span>
|
||||||
|
</div>
|
||||||
|
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="log-main">
|
||||||
|
<section class="st">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>log<span class="colon">:</span></h2>
|
||||||
|
<span class="count" id="log-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="messages">
|
||||||
|
<div class="empty">Loading events…</div>
|
||||||
|
</div>
|
||||||
|
<button class="loadmore" id="load-older" style="display:none">load older</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var PAGE_SIZE = 200;
|
||||||
|
var oldestTs = null; // ts of the oldest loaded row (pagination cursor)
|
||||||
|
var seq = 0; // guards against out-of-order fetch responses
|
||||||
|
var newestTs = null; // ts of the newest loaded row (dedup guard for the WS tail)
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClock(ts) {
|
||||||
|
var d = new Date(ts * 1000);
|
||||||
|
function p(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
var t = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||||
|
return (d.toDateString() === new Date().toDateString())
|
||||||
|
? t
|
||||||
|
: d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRowHtml(msg) {
|
||||||
|
var lvl = (msg.level || 'INFO');
|
||||||
|
var lc = lvl.toLowerCase();
|
||||||
|
var chipCls = lc === 'critical' ? 'crit' : (lc === 'warning' ? 'warn' : (lc === 'recover' ? 'ok' : ''));
|
||||||
|
var short = lc === 'critical' ? 'CRIT' : (lc === 'warning' ? 'WARN' : escHtml(lvl));
|
||||||
|
return '<div class="logrow">'
|
||||||
|
+ '<span class="t">' + fmtClock(msg.ts) + '</span>'
|
||||||
|
+ '<span class="lvlc"><span class="chip ' + chipCls + '">' + short + '</span></span>'
|
||||||
|
+ '<span class="h">' + escHtml(msg.host || '') + (msg.service ? ' · ' + escHtml(msg.service) : '') + '</span>'
|
||||||
|
+ '<span class="m">' + escHtml(msg.message || '') + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFilters() {
|
||||||
|
return {
|
||||||
|
host: document.getElementById('filter-host').value.trim(),
|
||||||
|
level: document.getElementById('filter-level').value,
|
||||||
|
q: document.getElementById('filter-msg').value.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilters(msg) {
|
||||||
|
var f = currentFilters();
|
||||||
|
if (f.host && !(msg.host || '').toLowerCase().includes(f.host.toLowerCase())) return false;
|
||||||
|
if (f.level && (msg.level || '').toLowerCase() !== f.level) return false;
|
||||||
|
if (f.q && !(msg.message || '').toLowerCase().includes(f.q.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
var n = document.querySelectorAll('#messages .logrow').length;
|
||||||
|
document.getElementById('log-count').textContent = n ? n + ' events loaded' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLog(reset) {
|
||||||
|
var mySeq = ++seq;
|
||||||
|
var box = document.getElementById('messages');
|
||||||
|
var btn = document.getElementById('load-older');
|
||||||
|
btn.disabled = true;
|
||||||
|
var f = currentFilters();
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
params.set('limit', String(PAGE_SIZE));
|
||||||
|
if (f.host) params.set('host', f.host);
|
||||||
|
if (f.level) params.set('level', f.level);
|
||||||
|
if (f.q) params.set('q', f.q);
|
||||||
|
if (!reset && oldestTs != null) params.set('before', String(oldestTs));
|
||||||
|
var data;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/0/log?' + params.toString());
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
if (mySeq === seq && reset) {
|
||||||
|
box.innerHTML = '<div class="empty">Failed to load events: ' + escHtml(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mySeq !== seq) return; // a newer request superseded this one
|
||||||
|
if (reset) { box.innerHTML = ''; oldestTs = null; }
|
||||||
|
var html = '';
|
||||||
|
data.events.forEach(function (ev) {
|
||||||
|
html += logRowHtml(ev);
|
||||||
|
if (ev.ts != null && (oldestTs == null || ev.ts < oldestTs)) oldestTs = ev.ts;
|
||||||
|
if (ev.ts != null && (newestTs == null || ev.ts > newestTs)) newestTs = ev.ts;
|
||||||
|
});
|
||||||
|
box.insertAdjacentHTML('beforeend', html);
|
||||||
|
if (!box.children.length) box.innerHTML = '<div class="empty">No events found.</div>';
|
||||||
|
btn.style.display = data.more ? '' : 'none';
|
||||||
|
btn.disabled = false;
|
||||||
|
updateCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange() { fetchLog(true); }
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
var t;
|
||||||
|
return function () { clearTimeout(t); t = setTimeout(fn, ms); };
|
||||||
|
}
|
||||||
|
document.getElementById('filter-host').addEventListener('input', debounce(onFilterChange, 300));
|
||||||
|
document.getElementById('filter-msg').addEventListener('input', debounce(onFilterChange, 300));
|
||||||
|
document.getElementById('filter-level').addEventListener('change', onFilterChange);
|
||||||
|
document.getElementById('load-older').addEventListener('click', function () { fetchLog(false); });
|
||||||
|
|
||||||
|
// ---- live tail over the shared websocket --------------------------------
|
||||||
|
function setWsStatus(ok) {
|
||||||
|
var el = document.getElementById('ws-status');
|
||||||
|
el.classList.toggle('reconnecting', !ok);
|
||||||
|
el.textContent = ok ? 'live' : 'reconnecting…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function WS_Connect() {
|
||||||
|
if (!("WebSocket" in window)) return;
|
||||||
|
var ws_hbd = new WebSocket("{{ heartbeat_ws_url }}");
|
||||||
|
ws_hbd.onopen = function () {
|
||||||
|
setWsStatus(true);
|
||||||
|
ws_hbd.send("heartbeat_web");
|
||||||
|
};
|
||||||
|
ws_hbd.onmessage = function (event) {
|
||||||
|
var state = JSON.parse(event.data);
|
||||||
|
// history replays are covered by the API seed; only tail new events
|
||||||
|
if (state.type !== "message" || state.history) return;
|
||||||
|
var msg = state.data;
|
||||||
|
if (!matchesFilters(msg)) return;
|
||||||
|
if (msg.ts != null && newestTs != null && msg.ts <= newestTs) return; // already loaded via the API
|
||||||
|
if (msg.ts != null && (newestTs == null || msg.ts > newestTs)) newestTs = msg.ts;
|
||||||
|
var box = document.getElementById('messages');
|
||||||
|
var placeholder = box.querySelector('.empty');
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
box.insertAdjacentHTML('afterbegin', logRowHtml(msg));
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
ws_hbd.onclose = function () {
|
||||||
|
setWsStatus(false);
|
||||||
|
setTimeout(WS_Connect, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- boot ----------------------------------------------------------------
|
||||||
|
fetchLog(true);
|
||||||
|
WS_Connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
|
||||||
s<header>{{ header }}</header> -->
|
|
||||||
@@ -6,12 +6,13 @@
|
|||||||
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
||||||
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||||
{% if current_user and current_user.admin %}
|
<a href="/log"{% if active_page == "log" %} class="active"{% endif %}>Log</a>
|
||||||
|
{% if current_user %}
|
||||||
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||||
{% 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 %}
|
{% if current_user %}
|
||||||
<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>
|
<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 %}
|
{% endif %}
|
||||||
<div class="nav-pie" title="Host alert status">
|
<div class="nav-pie" title="Host alert status">
|
||||||
|
|||||||
+283
-352
@@ -2,38 +2,30 @@
|
|||||||
<html>
|
<html>
|
||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/hbd-ui.css">
|
||||||
|
<script src="/static/hbd-ui.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body { overflow: hidden; }
|
body { overflow: hidden; background: var(--st-paper); }
|
||||||
|
|
||||||
|
/* This page scrolls inside .container, not the body — the toolbar is
|
||||||
|
always in view, and sticky misbehaves inside an overflow:hidden body. */
|
||||||
|
.st-toolbar { position: static; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-height: calc(100vh - 120px);
|
max-height: calc(100vh - var(--bars-h, 94px) - 30px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 10px;
|
padding: 0 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
/* ── Host cards: one record list per host ───────────────────── */
|
||||||
color: #333;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
margin-top: 15px;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Host cards ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.host-card {
|
.host-card {
|
||||||
background: white;
|
background: var(--st-surface);
|
||||||
border-radius: 6px;
|
border: 1px solid var(--st-line);
|
||||||
padding: 0;
|
border-radius: 8px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-header {
|
.host-header {
|
||||||
@@ -42,115 +34,74 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 10px 15px;
|
padding: 8px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
.host-header:hover { background: var(--st-surface-2); border-radius: 8px 8px 0 0; }
|
||||||
|
.host-card.collapsed .host-header:hover { border-radius: 8px; }
|
||||||
|
|
||||||
.host-header:hover { background: #f9f9f9; border-radius: 6px 6px 0 0; }
|
.host-left { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
.host-card.collapsed .host-header:hover { border-radius: 6px; }
|
|
||||||
|
|
||||||
.host-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon {
|
.collapse-icon {
|
||||||
font-size: 1em;
|
font-size: .85em;
|
||||||
color: #888;
|
color: var(--st-faint);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
min-width: 16px;
|
min-width: 14px;
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .collapse-icon, .acc-icon { transition: none; } }
|
||||||
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
|
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
|
||||||
|
|
||||||
.host-name {
|
.host-name {
|
||||||
font-size: 1.05em;
|
font-family: var(--st-mono);
|
||||||
font-weight: bold;
|
font-size: 13.5px;
|
||||||
color: #333;
|
font-weight: 600;
|
||||||
|
color: var(--st-ink);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Glance strip ───────────────────────────────────────────── */
|
/* ── Glance strip ───────────────────────────────────────────── */
|
||||||
|
.glance-strip { display: flex; align-items: center; gap: 5px; flex: 1; flex-wrap: wrap; padding: 0 8px; }
|
||||||
.glance-strip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glance-chip {
|
.glance-chip {
|
||||||
font-size: 0.78em;
|
display: inline-flex; align-items: center;
|
||||||
padding: 2px 9px;
|
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
|
||||||
border-radius: 10px;
|
padding: 3px 7px; border-radius: 4px; white-space: nowrap;
|
||||||
font-weight: 500;
|
background: var(--st-ok-soft); color: var(--st-ok);
|
||||||
white-space: nowrap;
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
}
|
||||||
|
.glance-chip.warn { background: var(--st-warn-soft); color: var(--st-warn); }
|
||||||
.glance-chip.warn { background: #fff3e0; color: #e65100; }
|
.glance-chip.crit { background: var(--st-crit-soft); color: var(--st-crit); }
|
||||||
.glance-chip.crit { background: #ffebee; color: #b71c1c; }
|
.glance-chip.neutral { background: var(--st-surface-2); color: var(--st-muted); }
|
||||||
.glance-chip.neutral { background: #f5f5f5; color: #555; }
|
.glance-loading { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
|
||||||
.glance-loading { font-size: 0.8em; color: #bbb; font-style: italic; }
|
|
||||||
|
|
||||||
/* ── Host right zone ────────────────────────────────────────── */
|
/* ── Host right zone ────────────────────────────────────────── */
|
||||||
|
.host-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
.host-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nagios-badge {
|
.nagios-badge {
|
||||||
font-size: 0.75em;
|
font-family: var(--st-mono); font-size: 10.5px; font-weight: 700;
|
||||||
font-weight: bold;
|
padding: 3px 9px; border-radius: 4px;
|
||||||
padding: 2px 10px;
|
background: var(--st-surface-2); color: var(--st-muted);
|
||||||
border-radius: 10px;
|
text-transform: uppercase; white-space: nowrap;
|
||||||
background: #9e9e9e;
|
|
||||||
color: white;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
.nagios-badge.ok { background: var(--st-ok-soft); color: var(--st-ok); }
|
||||||
.nagios-badge.ok { background: #4caf50; }
|
.nagios-badge.warning { background: var(--st-warn-soft); color: var(--st-warn); }
|
||||||
.nagios-badge.warning { background: #ff9800; }
|
.nagios-badge.critical { background: var(--st-crit-soft); color: var(--st-crit); }
|
||||||
.nagios-badge.critical { background: #f44336; }
|
|
||||||
|
|
||||||
.os-label {
|
.os-label {
|
||||||
font-size: 0.75em;
|
font-size: 11px; color: var(--st-faint); white-space: nowrap;
|
||||||
color: #999;
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-action-btn {
|
.host-action-btn {
|
||||||
font-size: 0.75em;
|
font: 600 12px/1 inherit;
|
||||||
font-weight: bold;
|
padding: 4px 10px; border-radius: 6px;
|
||||||
padding: 3px 10px;
|
border: 1px solid var(--st-line);
|
||||||
border-radius: 4px;
|
background: var(--st-surface);
|
||||||
border: none;
|
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.host-action-btn.update-btn {
|
.host-action-btn.update-btn { color: var(--st-accent); }
|
||||||
background: #e3f2fd;
|
.host-action-btn.update-btn:hover { background: var(--st-accent-soft); border-color: var(--st-accent); }
|
||||||
color: #1565c0;
|
.host-action-btn.delete-btn { color: var(--st-crit); }
|
||||||
}
|
.host-action-btn.delete-btn:hover { background: var(--st-crit-soft); border-color: var(--st-crit); }
|
||||||
.host-action-btn.update-btn:hover { background: #bbdefb; }
|
|
||||||
.host-action-btn.delete-btn {
|
|
||||||
background: #ffebee;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
|
|
||||||
|
|
||||||
/* ── Action result toast ───────────────────────────────────── */
|
/* ── Action result toast ───────────────────────────────────── */
|
||||||
#action-toast {
|
#action-toast {
|
||||||
@@ -158,8 +109,8 @@
|
|||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) translateY(20px);
|
transform: translateX(-50%) translateY(20px);
|
||||||
background: #323232;
|
background: var(--st-ink);
|
||||||
color: #fff;
|
color: var(--st-paper);
|
||||||
padding: 12px 22px;
|
padding: 12px 22px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -171,301 +122,176 @@
|
|||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
#action-toast.show {
|
#action-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
opacity: 1;
|
#action-toast.error { background: var(--st-crit); color: #fff; }
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
#action-toast.error { background: #c62828; }
|
|
||||||
|
|
||||||
/* ── Host body ──────────────────────────────────────────────── */
|
/* ── Host body ──────────────────────────────────────────────── */
|
||||||
|
.host-body { padding: 8px 14px 12px; border-top: 1px solid var(--st-line-soft); }
|
||||||
.host-body {
|
|
||||||
padding: 8px 15px 12px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-card.collapsed .host-body { display: none; }
|
.host-card.collapsed .host-body { display: none; }
|
||||||
|
|
||||||
/* ── Plugin accordions ──────────────────────────────────────── */
|
/* ── Plugin accordions ──────────────────────────────────────── */
|
||||||
|
|
||||||
.plugin-accordion {
|
.plugin-accordion {
|
||||||
border: 1px solid #e8e8e8;
|
border: 1px solid var(--st-line-soft);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-acc-header {
|
.plugin-acc-header {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 10px;
|
||||||
align-items: center;
|
padding: 6px 12px; cursor: pointer;
|
||||||
gap: 10px;
|
background: var(--st-surface-2);
|
||||||
padding: 7px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #fafafa;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.plugin-acc-header:hover { background: var(--st-accent-soft); }
|
||||||
|
|
||||||
.plugin-acc-header:hover { background: #f0f4ff; }
|
.acc-icon { font-size: 0.7em; color: var(--st-faint); transition: transform 0.15s; min-width: 12px; }
|
||||||
|
|
||||||
.acc-icon {
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: #999;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
min-width: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
|
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
|
||||||
|
|
||||||
.plugin-label {
|
.plugin-label {
|
||||||
font-weight: 600;
|
font-family: var(--st-mono);
|
||||||
font-size: 1.00em;
|
font-weight: 600; font-size: 12.5px;
|
||||||
color: #444;
|
color: var(--st-ink);
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
.plugin-summary { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); flex: 1; }
|
||||||
.plugin-summary {
|
|
||||||
font-size: 0.82em;
|
|
||||||
color: #888;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion.collapsed .plugin-acc-body { display: none; }
|
.plugin-accordion.collapsed .plugin-acc-body { display: none; }
|
||||||
|
|
||||||
.plugin-acc-body { padding: 10px 12px; }
|
.plugin-acc-body { padding: 10px 12px; }
|
||||||
|
|
||||||
/* ── Tables ─────────────────────────────────────────────────── */
|
/* ── Tables ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 1.00em;
|
font-size: 12.5px;
|
||||||
background: #fff;
|
background: var(--st-surface);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
border: 1px solid var(--st-line-soft);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.data-table thead { background: var(--st-surface-2); }
|
||||||
.data-table thead { background: #2196f3; color: white; }
|
|
||||||
|
|
||||||
.data-table th {
|
.data-table th {
|
||||||
padding: 7px 10px;
|
font-family: var(--st-mono);
|
||||||
text-align: left;
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
|
||||||
font-weight: 600;
|
color: var(--st-faint); font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-align: left; padding: 5px 8px;
|
||||||
font-size: 0.75em;
|
border-bottom: 1px solid var(--st-line);
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table th.num { text-align: right; }
|
.data-table th.num { text-align: right; }
|
||||||
.data-table th.center { text-align: center; }
|
.data-table th.center { text-align: center; }
|
||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
/* padding: 6px 10px; */
|
padding: 4px 8px;
|
||||||
border-top: 1px solid #e8e8e8;
|
border-top: 1px solid var(--st-line-soft);
|
||||||
color: #333;
|
color: var(--st-ink);
|
||||||
}
|
}
|
||||||
|
.data-table td.num { text-align: right; font-family: var(--st-mono); }
|
||||||
.data-table td.num {
|
|
||||||
text-align: right;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table td.center { text-align: center; }
|
.data-table td.center { text-align: center; }
|
||||||
.data-table td.key { color: #666; font-weight: 500; width: 38%; }
|
.data-table td.key { color: var(--st-muted); font-weight: 500; width: 38%; }
|
||||||
|
.data-table tbody tr:hover { background: var(--st-surface-2); }
|
||||||
|
|
||||||
.data-table tbody tr:nth-child(even) { background: #fafafa; }
|
.iface-name { font-family: var(--st-mono); font-weight: 600; color: var(--st-accent); }
|
||||||
.data-table tbody tr:hover { background: #f0f4ff; }
|
|
||||||
|
|
||||||
.iface-name { font-weight: bold; color: #2196f3; }
|
|
||||||
|
|
||||||
/* ── Percent bars ───────────────────────────────────────────── */
|
/* ── Percent bars ───────────────────────────────────────────── */
|
||||||
|
.bar-wrap { display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
||||||
.bar-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-track {
|
.bar-track {
|
||||||
display: inline-block;
|
flex: 1; height: 7px; border-radius: 4px;
|
||||||
width: 70px;
|
background: var(--st-line-soft); overflow: hidden; min-width: 60px;
|
||||||
height: 6px;
|
|
||||||
background: #e0e0e0;
|
|
||||||
border-radius: 3px;
|
|
||||||
vertical-align: middle;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
.bar-fill { height: 100%; border-radius: 4px; background: var(--st-ok); }
|
||||||
.bar-fill {
|
.bar-fill.warn { background: var(--st-warn); }
|
||||||
height: 6px;
|
.bar-fill.crit { background: var(--st-crit); }
|
||||||
border-radius: 3px;
|
|
||||||
background: #4caf50;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill.warn { background: #ff9800; }
|
|
||||||
.bar-fill.crit { background: #f44336; }
|
|
||||||
|
|
||||||
/* ── Disk two-table layout ──────────────────────────────────── */
|
/* ── Disk two-table layout ──────────────────────────────────── */
|
||||||
|
.flex-tables { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||||
.flex-tables {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-tables > div { flex: 1 1 380px; }
|
.flex-tables > div { flex: 1 1 380px; }
|
||||||
|
|
||||||
.table-section-label {
|
.table-section-label {
|
||||||
font-size: 0.78em;
|
font-family: var(--st-mono);
|
||||||
font-weight: 600;
|
font-size: 11px; font-weight: 600; color: var(--st-faint);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase; letter-spacing: .05em;
|
||||||
color: #888;
|
margin: 4px 0 6px;
|
||||||
letter-spacing: 0.4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status / misc ──────────────────────────────────────────── */
|
/* ── Status / misc ──────────────────────────────────────────── */
|
||||||
|
.status-up { color: var(--st-ok); font-weight: bold; }
|
||||||
|
.status-down { color: var(--st-crit); font-weight: bold; }
|
||||||
|
|
||||||
.status-up { color: #4caf50; font-weight: bold; }
|
.pct-ok { color: var(--st-ok); font-weight: bold; }
|
||||||
.status-down { color: #f44336; font-weight: bold; }
|
.pct-warn { color: var(--st-warn); font-weight: bold; }
|
||||||
|
.pct-crit { color: var(--st-crit); font-weight: bold; }
|
||||||
|
|
||||||
.pct-ok { color: #2e7d32; font-weight: bold; }
|
.check-ok { background: var(--st-ok-soft); }
|
||||||
.pct-warn { color: #e65100; font-weight: bold; }
|
.check-warning { background: var(--st-warn-soft); }
|
||||||
.pct-crit { color: #b71c1c; font-weight: bold; }
|
.check-critical { background: var(--st-crit-soft); }
|
||||||
|
.check-unknown { background: var(--st-surface-2); }
|
||||||
|
|
||||||
.check-ok { background: #e8f5e9; }
|
.check-status-ok { color: var(--st-ok); font-weight: bold; }
|
||||||
.check-warning { background: #fff3e0; }
|
.check-status-warning { color: var(--st-warn); font-weight: bold; }
|
||||||
.check-critical { background: #ffebee; }
|
.check-status-critical { color: var(--st-crit); font-weight: bold; }
|
||||||
.check-unknown { background: #f5f5f5; }
|
.check-status-unknown { color: var(--st-faint); font-weight: bold; }
|
||||||
|
|
||||||
.check-status-ok { color: #2e7d32; font-weight: bold; }
|
.check-output { font-size: 0.9em; color: var(--st-muted); }
|
||||||
.check-status-warning { color: #e65100; font-weight: bold; }
|
|
||||||
.check-status-critical { color: #b71c1c; font-weight: bold; }
|
|
||||||
.check-status-unknown { color: #777; font-weight: bold; }
|
|
||||||
|
|
||||||
.check-output { font-size: 0.9em; color: #555; }
|
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
color: #bbb;
|
font-size: 11px; color: var(--st-faint);
|
||||||
font-size: 0.75em;
|
margin-top: 6px; padding-top: 6px;
|
||||||
margin-top: 8px;
|
border-top: 1px solid var(--st-line-soft);
|
||||||
padding-top: 6px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #aaa;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 1.00em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-data { text-align: center; padding: 40px; color: var(--st-faint); }
|
||||||
|
.loading { text-align: center; padding: 12px; color: var(--st-faint); font-style: italic; }
|
||||||
.error {
|
.error {
|
||||||
background: #ffebee;
|
background: var(--st-crit-soft);
|
||||||
border-left: 3px solid #f44336;
|
border-left: 3px solid var(--st-crit);
|
||||||
padding: 8px 12px;
|
padding: 12px 16px; margin: 10px 0;
|
||||||
margin: 8px 0;
|
border-radius: 6px; color: var(--st-crit);
|
||||||
border-radius: 3px;
|
|
||||||
color: #c62828;
|
|
||||||
font-size: 1.00em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar ──────────────────────────────────────────────── */
|
/* ── Scrollbar ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.container::-webkit-scrollbar { width: 8px; }
|
.container::-webkit-scrollbar { width: 8px; }
|
||||||
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
.container::-webkit-scrollbar-track { background: var(--st-surface-2); border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
.container::-webkit-scrollbar-thumb { background: var(--st-line); border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
.container::-webkit-scrollbar-thumb:hover { background: var(--st-faint); }
|
||||||
|
|
||||||
/* ── Host info section ──────────────────────────────────────────────────── */
|
/* ── Host info section ──────────────────────────────────────── */
|
||||||
.host-info-section {
|
.host-info-section {
|
||||||
padding: 12px 16px;
|
background: var(--st-surface-2);
|
||||||
background: #fafafa;
|
border-radius: 6px;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
padding: 8px 12px;
|
||||||
font-size: 1.00em;
|
margin-bottom: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.info-meta {
|
.info-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||||||
display: grid;
|
.info-label { font-family: var(--st-mono); font-size: 11px; font-weight: 600; color: var(--st-faint); white-space: nowrap; }
|
||||||
grid-template-columns: max-content 1fr;
|
.info-value { color: var(--st-ink); }
|
||||||
gap: 3px 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
|
||||||
.info-value { color: #222; }
|
|
||||||
.info-thresholds-title {
|
.info-thresholds-title {
|
||||||
font-weight: 600;
|
font-family: var(--st-mono);
|
||||||
color: #555;
|
font-size: 11px; font-weight: 600; color: var(--st-faint);
|
||||||
margin-bottom: 6px;
|
text-transform: uppercase; letter-spacing: .05em;
|
||||||
|
margin: 8px 0 4px;
|
||||||
}
|
}
|
||||||
.info-note { color: #888; font-style: italic; }
|
.info-note { color: var(--st-faint); font-style: italic; }
|
||||||
.info-loading { color: #bbb; font-style: italic; }
|
.info-loading { color: var(--st-faint); font-style: italic; }
|
||||||
.threshold-covers { font-size: 1.00em; color: #777; font-style: italic; }
|
.threshold-covers { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
|
||||||
|
|
||||||
/* ── Dark mode ── */
|
|
||||||
html[data-theme="dark"] h1 { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .host-card { background: var(--surface); }
|
|
||||||
html[data-theme="dark"] .host-header:hover { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .host-name { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .collapse-icon,
|
|
||||||
html[data-theme="dark"] .acc-icon { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .host-body { border-top-color: var(--border-3); }
|
|
||||||
html[data-theme="dark"] .plugin-accordion { border-color: var(--border); }
|
|
||||||
html[data-theme="dark"] .plugin-acc-header { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .plugin-acc-header:hover { background: var(--surface-3); }
|
|
||||||
html[data-theme="dark"] .plugin-label { color: var(--text-2); }
|
|
||||||
html[data-theme="dark"] .plugin-summary { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .data-table { background: var(--surface); }
|
|
||||||
html[data-theme="dark"] .data-table td { border-top-color: var(--border); color: var(--text); }
|
|
||||||
html[data-theme="dark"] .data-table td.key { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .data-table tbody tr:nth-child(even) { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .data-table tbody tr:hover { background: #1e3a5f; }
|
|
||||||
html[data-theme="dark"] .bar-track { background: var(--border); }
|
|
||||||
html[data-theme="dark"] .table-section-label { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .no-data,
|
|
||||||
html[data-theme="dark"] .loading { color: var(--text-dim); }
|
|
||||||
html[data-theme="dark"] .timestamp { color: var(--text-dim); border-top-color: var(--border-3); }
|
|
||||||
html[data-theme="dark"] .glance-chip.neutral { background: var(--surface-3); color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .os-label { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .host-info-section { background: var(--surface-2); border-bottom-color: var(--border); }
|
|
||||||
html[data-theme="dark"] .info-label { color: var(--text-3); }
|
|
||||||
html[data-theme="dark"] .info-value { color: var(--text); }
|
|
||||||
html[data-theme="dark"] .info-thresholds-title { color: var(--text-3); }
|
|
||||||
html[data-theme="dark"] .info-note,
|
|
||||||
html[data-theme="dark"] .info-loading,
|
|
||||||
html[data-theme="dark"] .threshold-covers { color: var(--text-muted); }
|
|
||||||
html[data-theme="dark"] .check-ok { background: #0d2e17; }
|
|
||||||
html[data-theme="dark"] .check-warning { background: #2e1a00; }
|
|
||||||
html[data-theme="dark"] .check-critical { background: #2e0a0a; }
|
|
||||||
html[data-theme="dark"] .check-unknown { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .check-output { color: var(--text-sec); }
|
|
||||||
html[data-theme="dark"] .container::-webkit-scrollbar-track { background: var(--surface-2); }
|
|
||||||
html[data-theme="dark"] .container::-webkit-scrollbar-thumb { background: var(--border); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
|
<div class="st-toolbar">
|
||||||
|
<span class="brand">hbd<span class="tld">·overview</span></span>
|
||||||
|
<span class="hintline">per-host metrics — expand a host for plugin details</span>
|
||||||
|
</div>
|
||||||
|
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ header }}</h1>
|
|
||||||
<p class="subtitle">Per-host system metrics — expand a host to see plugin details</p>
|
|
||||||
|
|
||||||
{% if not hosts %}
|
{% if not hosts %}
|
||||||
<div class="no-data">
|
<div class="no-data">
|
||||||
<p>No hosts with plugin data available</p>
|
<p>No hosts have connected yet</p>
|
||||||
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending plugin metrics</p>
|
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending heartbeats</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div id="hosts-container">
|
<div id="hosts-container">
|
||||||
@@ -618,6 +444,34 @@
|
|||||||
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
if (data.connections && data.connections.length) {
|
||||||
|
html += `<div class="info-thresholds-title">Connectivity</div>`;
|
||||||
|
for (const c of data.connections) {
|
||||||
|
html += `<div class="info-note">${escHtml(c.family)} · ${escHtml(c.addr || '—')} RTT</div>
|
||||||
|
<div id="rtt-chart-${hostname}-${escHtml(c.family)}" style="margin-bottom:8px;"></div>`;
|
||||||
|
}
|
||||||
|
html += `<table class="data-table"><thead><tr>
|
||||||
|
<th>Family</th><th>Address</th><th>State</th>
|
||||||
|
<th class="num">RTT</th><th>Last Change</th><th>Last Packet</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
for (const c of data.connections) {
|
||||||
|
const st = escHtml(c.state || '—');
|
||||||
|
const stCls = c.state === 'up' ? 'status-up' : (c.state ? 'status-down' : '');
|
||||||
|
const rtt = (c.state === 'up' && c.rtt) ? Math.round(c.rtt) + ' ms' : '—';
|
||||||
|
const chg = c.statetime ? new Date(c.statetime * 1000).toLocaleString() : '—';
|
||||||
|
const seen = c.lastbeat ? new Date(c.lastbeat * 1000).toLocaleString() : '—';
|
||||||
|
html += `<tr>
|
||||||
|
<td class="key">${escHtml(c.family)}</td>
|
||||||
|
<td>${escHtml(c.addr || '—')}</td>
|
||||||
|
<td><span class="${stCls}">${st}</span></td>
|
||||||
|
<td class="num">${rtt}</td>
|
||||||
|
<td>${chg}</td>
|
||||||
|
<td>${seen}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.thresholds === null) {
|
if (data.thresholds === null) {
|
||||||
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
||||||
} else if (data.thresholds.length === 0) {
|
} else if (data.thresholds.length === 0) {
|
||||||
@@ -645,6 +499,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
if (data.connections && data.connections.length) {
|
||||||
|
const rttThresholds = (data.thresholds || []).find(t => t.metric === 'rtt') || null;
|
||||||
|
for (const c of data.connections) {
|
||||||
|
fetchRttHistory(hostname, c.family)
|
||||||
|
.then(samples => renderRttChart(hostname, c.family, samples, rttThresholds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHostGlance(hostname) {
|
async function fetchHostGlance(hostname) {
|
||||||
@@ -959,15 +822,11 @@
|
|||||||
return json.samples || [];
|
return json.samples || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCpuChart(hostname, samples) {
|
function renderTimeSeriesChart(elId, pts, opts) {
|
||||||
const el = document.getElementById(`cpu-chart-${hostname}`);
|
const el = document.getElementById(elId);
|
||||||
if (!el || !samples.length) return;
|
if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; }
|
||||||
|
|
||||||
const pts = samples
|
|
||||||
.filter(s => s.data.cpu_percent != null)
|
|
||||||
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
|
||||||
if (pts.length < 2) { el.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
|
const unitSuffix = opts.unitSuffix || '';
|
||||||
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
||||||
const cW = W - PAD.left - PAD.right;
|
const cW = W - PAD.left - PAD.right;
|
||||||
const cH = H - PAD.top - PAD.bottom;
|
const cH = H - PAD.top - PAD.bottom;
|
||||||
@@ -976,26 +835,49 @@
|
|||||||
const tRange = tMax - tMin || 1;
|
const tRange = tMax - tMin || 1;
|
||||||
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
||||||
|
|
||||||
// Auto-scale Y axis with 10% padding, clamped to [0, 100]
|
// Auto-scale Y axis with 10% padding, optionally clamped to opts.yDomain
|
||||||
const vMin = Math.min(...pts.map(p => p.v));
|
const vMin = Math.min(...pts.map(p => p.v));
|
||||||
const vMax = Math.max(...pts.map(p => p.v));
|
const vMax = Math.max(...pts.map(p => p.v));
|
||||||
const vRange = vMax - vMin || 1;
|
const vRange = vMax - vMin || 1;
|
||||||
const vPad = Math.max(vRange * 0.1, 1);
|
const vPad = Math.max(vRange * 0.1, 1);
|
||||||
const yLow = Math.max(0, vMin - vPad);
|
const domainLow = Array.isArray(opts.yDomain) ? opts.yDomain[0] : 0;
|
||||||
const yHigh = Math.min(100, vMax + vPad);
|
const domainHigh = Array.isArray(opts.yDomain) ? opts.yDomain[1] : Infinity;
|
||||||
|
const yLow = Math.max(domainLow, vMin - vPad);
|
||||||
|
const yHigh = Math.min(domainHigh, vMax + vPad);
|
||||||
const yRange = yHigh - yLow || 1;
|
const yRange = yHigh - yLow || 1;
|
||||||
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
|
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
|
||||||
|
|
||||||
// Build polyline points and filled area path
|
// Color based on latest value
|
||||||
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
|
|
||||||
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
|
|
||||||
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
|
|
||||||
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
|
|
||||||
|
|
||||||
// Color based on latest absolute CPU %
|
|
||||||
const latest = pts[pts.length - 1].v;
|
const latest = pts[pts.length - 1].v;
|
||||||
const strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
|
const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest);
|
||||||
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
|
|
||||||
|
// Split into segments wherever the gap between consecutive samples is
|
||||||
|
// much larger than the typical spacing (e.g. the host was overdue/down
|
||||||
|
// for a while) — draw each segment separately so missing data reads as
|
||||||
|
// a visual gap instead of an interpolated line across dead time.
|
||||||
|
const deltas = [];
|
||||||
|
for (let i = 1; i < pts.length; i++) deltas.push(pts[i].t - pts[i - 1].t);
|
||||||
|
deltas.sort((a, b) => a - b);
|
||||||
|
const medianDelta = deltas[Math.floor(deltas.length / 2)];
|
||||||
|
const gapThreshold = medianDelta * 2.5;
|
||||||
|
|
||||||
|
const segments = [[pts[0]]];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
if (pts[i].t - pts[i - 1].t > gapThreshold) segments.push([]);
|
||||||
|
segments[segments.length - 1].push(pts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let linePolylines = '';
|
||||||
|
let areaPaths = '';
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg.length < 2) continue;
|
||||||
|
const segPoints = seg.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
|
||||||
|
linePolylines += `<polyline points="${segPoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
||||||
|
const segArea = `M${x(seg[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
|
||||||
|
seg.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
|
||||||
|
` L${x(seg[seg.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
|
||||||
|
areaPaths += `<path d="${segArea}" fill="${fillColor}" opacity="0.6"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute nice tick step for ~3-5 grid lines
|
// Compute nice tick step for ~3-5 grid lines
|
||||||
const rawStep = yRange / 4;
|
const rawStep = yRange / 4;
|
||||||
@@ -1005,7 +887,7 @@
|
|||||||
let gridLines = '';
|
let gridLines = '';
|
||||||
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
||||||
const yy = y(v).toFixed(1);
|
const yy = y(v).toFixed(1);
|
||||||
const label = Number.isInteger(v) ? v : v.toFixed(1);
|
const label = (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix;
|
||||||
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
|
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
|
||||||
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`;
|
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`;
|
||||||
}
|
}
|
||||||
@@ -1022,21 +904,65 @@
|
|||||||
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
|
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
|
||||||
style="width:100%;height:${H}px;display:block;">
|
style="width:100%;height:${H}px;display:block;">
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="cpu-clip-${hostname}">
|
<clipPath id="${opts.clipId}">
|
||||||
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
${gridLines}
|
${gridLines}
|
||||||
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
||||||
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
||||||
<g clip-path="url(#cpu-clip-${hostname})">
|
<g clip-path="url(#${opts.clipId})">
|
||||||
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
|
${areaPaths}
|
||||||
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
|
${linePolylines}
|
||||||
</g>
|
</g>
|
||||||
${xLabels}
|
${xLabels}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCpuChart(hostname, samples) {
|
||||||
|
const pts = samples
|
||||||
|
.filter(s => s.data.cpu_percent != null)
|
||||||
|
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
||||||
|
|
||||||
|
renderTimeSeriesChart(`cpu-chart-${hostname}`, pts, {
|
||||||
|
yDomain: [0, 100],
|
||||||
|
clipId: `cpu-clip-${hostname}`,
|
||||||
|
colorFor: (latest) => ({
|
||||||
|
stroke: latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047',
|
||||||
|
fill: latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRttHistory(hostname, family) {
|
||||||
|
const plugin = `rtt_${family.toLowerCase()}`;
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/${plugin}?limit=100`);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const json = await r.json();
|
||||||
|
return json.samples || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRttChart(hostname, family, samples, rttThresholds) {
|
||||||
|
const pts = samples
|
||||||
|
.filter(s => s.data.rtt != null && s.data.rtt > 0)
|
||||||
|
.map(s => ({ t: s.timestamp, v: s.data.rtt }));
|
||||||
|
|
||||||
|
renderTimeSeriesChart(`rtt-chart-${hostname}-${family}`, pts, {
|
||||||
|
yDomain: 'auto',
|
||||||
|
clipId: `rtt-clip-${hostname}-${family}`,
|
||||||
|
unitSuffix: ' ms',
|
||||||
|
colorFor: (latest) => {
|
||||||
|
if (rttThresholds?.critical != null && latest > rttThresholds.critical) {
|
||||||
|
return { stroke: '#e53935', fill: '#ffcdd2' };
|
||||||
|
}
|
||||||
|
if (rttThresholds?.warning != null && latest > rttThresholds.warning) {
|
||||||
|
return { stroke: '#fb8c00', fill: '#ffe0b2' };
|
||||||
|
}
|
||||||
|
return { stroke: '#1976d2', fill: '#bbdefb' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderCpuTable(hostname, d) {
|
function renderCpuTable(hostname, d) {
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
['cpu_percent', 'CPU Usage', 'bar'],
|
['cpu_percent', 'CPU Usage', 'bar'],
|
||||||
@@ -1443,6 +1369,11 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
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;
|
||||||
if (!GLANCE_PLUGINS.includes(pname)) {
|
if (!GLANCE_PLUGINS.includes(pname)) {
|
||||||
|
|||||||
@@ -240,7 +240,6 @@
|
|||||||
}
|
}
|
||||||
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
.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-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; }
|
.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 { 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-edit:hover { background: #666; }
|
||||||
@@ -465,7 +464,7 @@
|
|||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>My Channels</h2>
|
<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>
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own are private to you. Global channels are managed by administrators.</p>
|
||||||
<div id="my-channels-list">
|
<div id="my-channels-list">
|
||||||
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
||||||
{% for ch in my_channels %}
|
{% for ch in my_channels %}
|
||||||
@@ -473,7 +472,6 @@
|
|||||||
<div class="my-ch-header">
|
<div class="my-ch-header">
|
||||||
<span class="my-ch-name">{{ ch.name | e }}</span>
|
<span class="my-ch-name">{{ ch.name | e }}</span>
|
||||||
<span class="my-ch-type">{{ ch.type | 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">
|
<span class="my-ch-actions">
|
||||||
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
||||||
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
||||||
@@ -513,11 +511,6 @@
|
|||||||
<option value="CRITICAL">CRITICAL only</option>
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 id="my-ch-modal-status" class="ch-modal-status"></div>
|
||||||
<div class="ch-modal-footer">
|
<div class="ch-modal-footer">
|
||||||
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
||||||
@@ -744,7 +737,6 @@
|
|||||||
document.getElementById('my-ch-type').value = '';
|
document.getElementById('my-ch-type').value = '';
|
||||||
document.getElementById('my-ch-type-fields').innerHTML = '';
|
document.getElementById('my-ch-type-fields').innerHTML = '';
|
||||||
document.getElementById('my-ch-min-level').value = 'WARNING';
|
document.getElementById('my-ch-min-level').value = 'WARNING';
|
||||||
document.getElementById('my-ch-private').checked = false;
|
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
try {
|
try {
|
||||||
@@ -755,7 +747,6 @@
|
|||||||
document.getElementById('my-ch-type').value = ch.type;
|
document.getElementById('my-ch-type').value = ch.type;
|
||||||
onMyChTypeChange();
|
onMyChTypeChange();
|
||||||
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
document.getElementById('my-ch-private').checked = ch.private || false;
|
|
||||||
(ch.fields || []).forEach(f => {
|
(ch.fields || []).forEach(f => {
|
||||||
const inp = document.getElementById('mychf-' + f.key);
|
const inp = document.getElementById('mychf-' + f.key);
|
||||||
if (inp) inp.value = f.value || '';
|
if (inp) inp.value = f.value || '';
|
||||||
@@ -774,14 +765,13 @@
|
|||||||
const name = document.getElementById('my-ch-name').value.trim();
|
const name = document.getElementById('my-ch-name').value.trim();
|
||||||
const type = document.getElementById('my-ch-type').value;
|
const type = document.getElementById('my-ch-type').value;
|
||||||
const minLevel = document.getElementById('my-ch-min-level').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');
|
const statusEl = document.getElementById('my-ch-modal-status');
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
|
||||||
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
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; }
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
const body = { name, type, min_level: minLevel };
|
||||||
if (_myChSchemas[type]) {
|
if (_myChSchemas[type]) {
|
||||||
(_myChSchemas[type].fields || []).forEach(sf => {
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
const inp = document.getElementById('mychf-' + sf.key);
|
const inp = document.getElementById('mychf-' + sf.key);
|
||||||
|
|||||||
+1012
-1345
File diff suppressed because it is too large
Load Diff
@@ -1251,6 +1251,7 @@ class ThresholdChecker:
|
|||||||
title=title,
|
title=title,
|
||||||
body=body,
|
body=body,
|
||||||
level=lvl,
|
level=lvl,
|
||||||
|
service=short_path,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1534,6 +1535,7 @@ class ThresholdChecker:
|
|||||||
title=f"[REMINDER/{alert_state.level.name}] {host_name} {short_path}",
|
title=f"[REMINDER/{alert_state.level.name}] {host_name} {short_path}",
|
||||||
body=body,
|
body=body,
|
||||||
level=alert_state.level.name,
|
level=alert_state.level.name,
|
||||||
|
service=short_path,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
logger.info("Re-notification sent: %s", message)
|
logger.info("Re-notification sent: %s", message)
|
||||||
@@ -1554,6 +1556,10 @@ class ThresholdChecker:
|
|||||||
configured = self.get_thresholds_for_host(hostname)
|
configured = self.get_thresholds_for_host(hostname)
|
||||||
stale = []
|
stale = []
|
||||||
for mp in host.alert_states:
|
for mp in host.alert_states:
|
||||||
|
# connectivity.* and rtt are managed by the connection state
|
||||||
|
# machine, not by threshold config — never purge them.
|
||||||
|
if mp == "rtt" or mp.startswith("connectivity"):
|
||||||
|
continue
|
||||||
if self._find_threshold(configured, mp)[0] is not None:
|
if self._find_threshold(configured, mp)[0] is not None:
|
||||||
continue
|
continue
|
||||||
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
|
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
|
||||||
|
|||||||
+85
-8
@@ -16,6 +16,11 @@ from . import notify as notify_mod
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
# Plugin data is declared stale after this many collection intervals without a
|
||||||
|
# fresh sample. >= 2 so a single missed sample never purges live data (the user
|
||||||
|
# directive: wait at least two full intervals after recovery before going stale).
|
||||||
|
_STALE_INTERVAL_MULTIPLIER = 3
|
||||||
|
|
||||||
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
||||||
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
||||||
# Python's socket module on all platforms
|
# Python's socket module on all platforms
|
||||||
@@ -266,10 +271,15 @@ def restore_connection_timers(hbdclass, ctx):
|
|||||||
for afam, conn in list(host.connections.items()):
|
for afam, conn in list(host.connections.items()):
|
||||||
state = conn.getstate()
|
state = conn.getstate()
|
||||||
if state == hbdclass.Connection.DOWN:
|
if state == hbdclass.Connection.DOWN:
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
|
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
|
||||||
|
|
||||||
|
if state == hbdclass.Connection.UNKNOWN:
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
|
continue
|
||||||
|
|
||||||
if state == hbdclass.Connection.UP and interval > 0:
|
if state == hbdclass.Connection.UP and interval > 0:
|
||||||
elapsed = now - conn.lastbeat
|
elapsed = now - conn.lastbeat
|
||||||
# Give hosts one full (interval + grace) of extra time on startup
|
# Give hosts one full (interval + grace) of extra time on startup
|
||||||
@@ -300,11 +310,25 @@ def restore_connection_timers(hbdclass, ctx):
|
|||||||
"Restored OVERDUE timer %s/%s: %.0fs remaining",
|
"Restored OVERDUE timer %s/%s: %.0fs remaining",
|
||||||
uname, afam, remaining,
|
uname, afam, remaining,
|
||||||
)
|
)
|
||||||
|
# Ensure the connectivity alert is set — it may be missing if
|
||||||
|
# hbd was shut down before the on_overdue callback had a chance
|
||||||
|
# to record it.
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
restored += 1
|
restored += 1
|
||||||
|
|
||||||
logger.info("Restored timers for %d connection(s)", restored)
|
logger.info("Restored timers for %d connection(s)", restored)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rtt_key(plugin_name: str) -> bool:
|
||||||
|
"""True for synthetic RTT-history keys (rtt_ipv4/rtt_ipv6), not real plugin data."""
|
||||||
|
return plugin_name.startswith("rtt_")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_real_plugin_data(plugin_data: dict) -> bool:
|
||||||
|
"""True if plugin_data holds any real (non-RTT) client-collected plugin data."""
|
||||||
|
return any(not _is_rtt_key(k) for k in plugin_data)
|
||||||
|
|
||||||
|
|
||||||
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||||
"""Handle a parsed datagram message.
|
"""Handle a parsed datagram message.
|
||||||
|
|
||||||
@@ -371,7 +395,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
host.doesack = msg.get("acks", -1)
|
host.doesack = msg.get("acks", -1)
|
||||||
# send ACK back; ask client to resend plugin info when we have none yet
|
# send ACK back; ask client to resend plugin info when we have none yet
|
||||||
rmsg = {"time": time.time()}
|
rmsg = {"time": time.time()}
|
||||||
if not host.plugin_data:
|
if not _has_real_plugin_data(host.plugin_data):
|
||||||
rmsg["request_update"] = 1
|
rmsg["request_update"] = 1
|
||||||
opkt = dicttos("ACK", rmsg)
|
opkt = dicttos("ACK", rmsg)
|
||||||
try:
|
try:
|
||||||
@@ -385,20 +409,50 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
plugin_name = msg.get("plugin")
|
plugin_name = msg.get("plugin")
|
||||||
if plugin_name:
|
if plugin_name:
|
||||||
# Extract plugin fields, dropping protocol metadata fields
|
# Extract plugin fields, dropping protocol metadata fields
|
||||||
|
# (_interval is the client-declared collection interval, not a metric).
|
||||||
plugin_data = {k: v for k, v in msg.items()
|
plugin_data = {k: v for k, v in msg.items()
|
||||||
if k not in ("ID", "plugin", "id", "name")}
|
if k not in ("ID", "plugin", "id", "name", "_interval")}
|
||||||
# Store plugin data with timestamp
|
# Store plugin data with timestamp
|
||||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||||
# Reset stale timer — 3× the heartbeat interval (min 60 s)
|
|
||||||
stale_timeout = max(host.interval * 3, 60)
|
# Set the stale timer. Prefer the interval the client declares: it is
|
||||||
host.reset_plugin_timer(plugin_name, stale_timeout,
|
# the true collection cadence and is correct from the very first
|
||||||
_make_plugin_stale_callback(uname, ctx))
|
# post-recovery sample, so we never purge data too early after an
|
||||||
|
# outage. interval == 0 means a collect-once InfoPlugin that never
|
||||||
|
# goes stale. Legacy clients omit _interval, so fall back to inferring
|
||||||
|
# the cadence from the gap between the last two received samples.
|
||||||
|
history = host.plugin_data.get(plugin_name, [])
|
||||||
|
declared = msg.get("_interval")
|
||||||
|
if declared is not None:
|
||||||
|
if declared > 0:
|
||||||
|
host.reset_plugin_timer(plugin_name, declared * _STALE_INTERVAL_MULTIPLIER,
|
||||||
|
_make_plugin_stale_callback(uname, ctx))
|
||||||
|
else:
|
||||||
|
host.cancel_plugin_timer(plugin_name)
|
||||||
|
elif len(history) >= 2:
|
||||||
|
plugin_interval = max(history[-1][0] - history[-2][0], 1)
|
||||||
|
host.reset_plugin_timer(plugin_name, plugin_interval * _STALE_INTERVAL_MULTIPLIER,
|
||||||
|
_make_plugin_stale_callback(uname, ctx))
|
||||||
|
else:
|
||||||
|
host.cancel_plugin_timer(plugin_name)
|
||||||
|
|
||||||
|
# Remove alert states for metrics present in the previous sample but
|
||||||
|
# absent now (e.g. a nagios check removed from configuration).
|
||||||
|
if len(history) >= 2:
|
||||||
|
prev_keys = set(history[-2][1].keys())
|
||||||
|
curr_keys = set(plugin_data.keys())
|
||||||
|
for metric_name in prev_keys - curr_keys:
|
||||||
|
metric_path = f"{plugin_name}.{metric_name}"
|
||||||
|
if host.alert_states.pop(metric_path, None) is not None:
|
||||||
|
eventlog(uname, "INFO", f"stale check removed: {metric_path}")
|
||||||
|
if (prev_keys - curr_keys) and msg_to_websockets:
|
||||||
|
msg_to_websockets("host", host.stateinfo())
|
||||||
|
|
||||||
# If os_info reports an owner and none is configured server-side, apply it
|
# If os_info reports an owner and none is configured server-side, apply it
|
||||||
if plugin_name == "os_info":
|
if plugin_name == "os_info":
|
||||||
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
||||||
default_owner = config_mod.get_default_owner(cfg)
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
inferred_owner = config_owner or plugin_data.get("owner") or default_owner
|
||||||
host.owner = inferred_owner
|
host.owner = inferred_owner
|
||||||
logger.info(f"owner for {uname} is {host.owner}")
|
logger.info(f"owner for {uname} is {host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
@@ -453,6 +507,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
boot = msg.get("boot", 0)
|
boot = msg.get("boot", 0)
|
||||||
|
|
||||||
if boot:
|
if boot:
|
||||||
|
# hbc was stared with a -b flag
|
||||||
eventlog(uname, "INFO", "booted")
|
eventlog(uname, "INFO", "booted")
|
||||||
if host.watched:
|
if host.watched:
|
||||||
asyncio.create_task(notify_mod.send_notification(
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
@@ -460,11 +515,29 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
||||||
))
|
))
|
||||||
if message:
|
if message:
|
||||||
eventlog(uname, "INFO", "msg: %s" % message, service=service)
|
eventlog(uname, "INFO", message, service=service)
|
||||||
|
|
||||||
if conn.getstate() != hbdcls.Connection.UP:
|
if conn.getstate() != hbdcls.Connection.UP:
|
||||||
|
# Transition to UP and log/notify if appropriate
|
||||||
lasts = conn.state
|
lasts = conn.state
|
||||||
d = conn.newstate(hbdcls.Connection.UP, now)
|
d = conn.newstate(hbdcls.Connection.UP, now)
|
||||||
|
if boot:
|
||||||
|
# On reboot, pre-boot plugin data and derived alerts are stale.
|
||||||
|
# Cancel all plugin timers and wipe plugin state so timers restart
|
||||||
|
# cleanly from the first two post-boot samples. An ordinary
|
||||||
|
# reconnect (no boot flag) doesn't invalidate the client's
|
||||||
|
# already-collected data, so it's left alone — this keeps chart
|
||||||
|
# history intact across a transient network blip.
|
||||||
|
for pname in list(host.plugin_timers):
|
||||||
|
host.cancel_plugin_timer(pname)
|
||||||
|
for pname in [k for k in host.plugin_data if not _is_rtt_key(k)]:
|
||||||
|
del host.plugin_data[pname]
|
||||||
|
stale_plugin_keys = [
|
||||||
|
k for k in host.alert_states
|
||||||
|
if k not in ("rtt",) and not k.startswith("connectivity.")
|
||||||
|
]
|
||||||
|
for k in stale_plugin_keys:
|
||||||
|
del host.alert_states[k]
|
||||||
# Clear connectivity alert now that the host is back up
|
# Clear connectivity alert now that the host is back up
|
||||||
_set_connectivity_alert(host, conn.afam, "OK")
|
_set_connectivity_alert(host, conn.afam, "OK")
|
||||||
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
||||||
@@ -504,6 +577,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
if interval > 0:
|
if interval > 0:
|
||||||
host.interval = interval
|
host.interval = interval
|
||||||
|
|
||||||
|
# Record RTT history for charting
|
||||||
|
if rtt is not None:
|
||||||
|
host.add_plugin_data(f"rtt_{conn.afam.lower()}", {"rtt": rtt}, timestamp=now)
|
||||||
|
|
||||||
# Timer-based reachability monitoring
|
# Timer-based reachability monitoring
|
||||||
# Reset overdue timer on every heartbeat
|
# Reset overdue timer on every heartbeat
|
||||||
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ async def handler(request):
|
|||||||
# the client knows to append rather than prepend).
|
# the client knows to append rather than prepend).
|
||||||
if data.msgs:
|
if data.msgs:
|
||||||
try:
|
try:
|
||||||
for m in reversed(data.msgs):
|
for m in reversed(data.msgs[-30:]):
|
||||||
host_name = m.get("host") if isinstance(m, dict) else None
|
host_name = m.get("host") if isinstance(m, dict) else None
|
||||||
if not host_name or _user_can_see_host(user, host_name):
|
if not host_name or _user_can_see_host(user, host_name):
|
||||||
await ws.send_str(json.dumps({"type": "message", "data": m, "history": True}))
|
await ws.send_str(json.dumps({"type": "message", "data": m, "history": True}))
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.10"
|
version = "5.4.2"
|
||||||
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"
|
||||||
|
|||||||
+25
-5
@@ -375,6 +375,10 @@ static const char *jstr(const jval_t *v, const char *def) {
|
|||||||
* Config
|
* Config
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* Set from config in cfg_load. Off by default: a CMD packet is an unauthenticated
|
||||||
|
* UDP datagram, so executing one must be opted into per host. */
|
||||||
|
static bool g_allow_remote_command = false;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int hb_port, interval;
|
int hb_port, interval;
|
||||||
char owner[256];
|
char owner[256];
|
||||||
@@ -429,6 +433,9 @@ static void config_load(config_t *cfg, const char *path) {
|
|||||||
if ((v = jget(root, "hb_port"))) cfg->hb_port = jint(v, cfg->hb_port);
|
if ((v = jget(root, "hb_port"))) cfg->hb_port = jint(v, cfg->hb_port);
|
||||||
if ((v = jget(root, "interval"))) cfg->interval = jint(v, cfg->interval);
|
if ((v = jget(root, "interval"))) cfg->interval = jint(v, cfg->interval);
|
||||||
if ((v = jget(root, "owner"))) snprintf(cfg->owner, sizeof(cfg->owner), "%s", jstr(v, ""));
|
if ((v = jget(root, "owner"))) snprintf(cfg->owner, sizeof(cfg->owner), "%s", jstr(v, ""));
|
||||||
|
if ((v = jget(root, "allow_remote_command"))) g_allow_remote_command = jint(v, 0) != 0;
|
||||||
|
if (g_allow_remote_command)
|
||||||
|
LOGI("allow_remote_command is true - CMD packets from the server will be executed");
|
||||||
|
|
||||||
jval_t *plugins = jget(root, "plugins");
|
jval_t *plugins = jget(root, "plugins");
|
||||||
|
|
||||||
@@ -591,7 +598,13 @@ static void conn_recv(conn_t *c) {
|
|||||||
LOGD("ACK rtt=%.1fms", c->rtt);
|
LOGD("ACK rtt=%.1fms", c->rtt);
|
||||||
} else if (strcmp(id, "CMD") == 0) {
|
} else if (strcmp(id, "CMD") == 0) {
|
||||||
const char *cmd = kv_get(&msg, "cmd");
|
const char *cmd = kv_get(&msg, "cmd");
|
||||||
if (cmd) {
|
if (cmd && !g_allow_remote_command) {
|
||||||
|
LOGI("refused command (allow_remote_command is false): %s", cmd);
|
||||||
|
kvdict_t rep; kv_clear(&rep);
|
||||||
|
kv_set(&rep, "service", "command");
|
||||||
|
kv_set(&rep, "msg", "Refused: allow_remote_command is false");
|
||||||
|
conn_send(c, "HTB", &rep);
|
||||||
|
} else if (cmd) {
|
||||||
LOGI("CMD: %s", cmd);
|
LOGI("CMD: %s", cmd);
|
||||||
char out[4096] = "";
|
char out[4096] = "";
|
||||||
FILE *p = popen(cmd, "r");
|
FILE *p = popen(cmd, "r");
|
||||||
@@ -667,6 +680,7 @@ static void plugin_os_info(conn_t *c, const config_t *cfg) {
|
|||||||
if (osver[0]) kv_set(&d, "distro_version_id", osver);
|
if (osver[0]) kv_set(&d, "distro_version_id", osver);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
kv_set_int(&d, "_interval", 0); /* InfoPlugin: collect-once, never stale */
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGI("sent os_info");
|
LOGI("sent os_info");
|
||||||
}
|
}
|
||||||
@@ -781,6 +795,7 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
}
|
}
|
||||||
read_cpu_extras(&d);
|
read_cpu_extras(&d);
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->cpu_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent cpu_monitor");
|
LOGD("sent cpu_monitor");
|
||||||
}
|
}
|
||||||
@@ -796,7 +811,7 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
static void mem_send(conn_t *c,
|
static void mem_send(conn_t *c,
|
||||||
long long tot, long long used, long long av, long long fr,
|
long long tot, long long used, long long av, long long fr,
|
||||||
long long act, long long ina, long long cac, long long buf,
|
long long act, long long ina, long long cac, long long buf,
|
||||||
long long stot, long long sused) {
|
long long stot, long long sused, int interval) {
|
||||||
kvdict_t d; kv_clear(&d);
|
kvdict_t d; kv_clear(&d);
|
||||||
kv_set(&d, "plugin", "memory_monitor");
|
kv_set(&d, "plugin", "memory_monitor");
|
||||||
kv_set_ull(&d, "memory_total", (unsigned long long)tot);
|
kv_set_ull(&d, "memory_total", (unsigned long long)tot);
|
||||||
@@ -819,6 +834,7 @@ static void mem_send(conn_t *c,
|
|||||||
kv_set(&d, "swap_percent", pct);
|
kv_set(&d, "swap_percent", pct);
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent memory_monitor");
|
LOGD("sent memory_monitor");
|
||||||
}
|
}
|
||||||
@@ -863,7 +879,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
/* values from /proc/meminfo are in kB */
|
/* values from /proc/meminfo are in kB */
|
||||||
mem_send(c, tot*1024, used*1024, av*1024, fr*1024,
|
mem_send(c, tot*1024, used*1024, av*1024, fr*1024,
|
||||||
act*1024, ina*1024, cac*1024, buf*1024,
|
act*1024, ina*1024, cac*1024, buf*1024,
|
||||||
stot*1024, (stot-sfr)*1024);
|
stot*1024, (stot-sfr)*1024, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#elif defined(__FreeBSD__) || defined(__DragonFly__)
|
#elif defined(__FreeBSD__) || defined(__DragonFly__)
|
||||||
@@ -889,7 +905,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
long long cac = (long long)v_cache * ps;
|
long long cac = (long long)v_cache * ps;
|
||||||
long long av = fr + ina + cac; if (av > tot) av = tot;
|
long long av = fr + ina + cac; if (av > tot) av = tot;
|
||||||
long long used = tot - av;
|
long long used = tot - av;
|
||||||
mem_send(c, tot, used, av, fr, act, ina, cac, 0, 0, 0);
|
mem_send(c, tot, used, av, fr, act, ina, cac, 0, 0, 0, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#elif defined(__NetBSD__)
|
#elif defined(__NetBSD__)
|
||||||
@@ -910,7 +926,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
long long used = tot - av;
|
long long used = tot - av;
|
||||||
long long stot = (long long)uvm.swpages * ps;
|
long long stot = (long long)uvm.swpages * ps;
|
||||||
long long sinuse = (long long)uvm.swpginuse * ps;
|
long long sinuse = (long long)uvm.swpginuse * ps;
|
||||||
mem_send(c, tot, used, av, fr, act, ina, 0, 0, stot, sinuse);
|
mem_send(c, tot, used, av, fr, act, ina, 0, 0, stot, sinuse, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif /* platform memory */
|
#endif /* platform memory */
|
||||||
@@ -962,6 +978,7 @@ static void plugin_disk_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
char *jval = malloc(MAX_VAL + 1);
|
char *jval = malloc(MAX_VAL + 1);
|
||||||
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "partitions", jval); free(jval); }
|
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "partitions", jval); free(jval); }
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->disk_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
free(json);
|
free(json);
|
||||||
LOGD("sent disk_monitor");
|
LOGD("sent disk_monitor");
|
||||||
@@ -1067,6 +1084,7 @@ static void plugin_network_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
char *jval = malloc(MAX_VAL + 1);
|
char *jval = malloc(MAX_VAL + 1);
|
||||||
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "interfaces", jval); free(jval); }
|
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "interfaces", jval); free(jval); }
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->net_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
free(json);
|
free(json);
|
||||||
LOGD("sent network_monitor");
|
LOGD("sent network_monitor");
|
||||||
@@ -1125,6 +1143,7 @@ static void plugin_ping_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->ping_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent ping_monitor");
|
LOGD("sent ping_monitor");
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1213,7 @@ static void plugin_nagios_runner(conn_t *c, const config_t *cfg) {
|
|||||||
parse_perfdata(output, &d, name);
|
parse_perfdata(output, &d, name);
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->nagios_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent nagios_runner");
|
LOGD("sent nagios_runner");
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -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.10"
|
__version__ = "5.4.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
@@ -115,9 +115,14 @@ _DEFAULTS: Dict[str, Any] = {
|
|||||||
"hb_port": 50003,
|
"hb_port": 50003,
|
||||||
"interval": 10,
|
"interval": 10,
|
||||||
"owner": None,
|
"owner": None,
|
||||||
|
"allow_remote_command": False, # Execute shell commands received in CMD packets
|
||||||
"plugins": {},
|
"plugins": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Set from config in main(). Off by default: a CMD packet is an unauthenticated
|
||||||
|
# UDP datagram, so executing one must be opted into per host.
|
||||||
|
_allow_remote_command = False
|
||||||
|
|
||||||
|
|
||||||
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
cfg = dict(_DEFAULTS)
|
cfg = dict(_DEFAULTS)
|
||||||
@@ -870,6 +875,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
return
|
return
|
||||||
log = logging.getLogger("hbc.cmd")
|
log = logging.getLogger("hbc.cmd")
|
||||||
|
if not _allow_remote_command:
|
||||||
|
log.warning("refused command (allow_remote_command is false): %s", cmd)
|
||||||
|
await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"})
|
||||||
|
return
|
||||||
log.info("exec: %s", cmd)
|
log.info("exec: %s", cmd)
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
@@ -955,7 +964,7 @@ async def _run_info_plugins(conn: AsyncConnection, plugins: List[Plugin]):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
log.info("sent %s", plugin.name)
|
log.info("sent %s", plugin.name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("%s collect: %s", plugin.name, e)
|
log.error("%s collect: %s", plugin.name, e)
|
||||||
@@ -968,7 +977,7 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
log.debug("sent %s", plugin.name)
|
log.debug("sent %s", plugin.name)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -1180,6 +1189,11 @@ def main(argv=None):
|
|||||||
|
|
||||||
cfg = _load_config(args.configfile)
|
cfg = _load_config(args.configfile)
|
||||||
|
|
||||||
|
global _allow_remote_command
|
||||||
|
_allow_remote_command = bool(cfg.get("allow_remote_command", False))
|
||||||
|
if _allow_remote_command:
|
||||||
|
logging.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
||||||
|
|
||||||
if args.daemon:
|
if args.daemon:
|
||||||
_daemonize()
|
_daemonize()
|
||||||
_reconfigure_syslog(level)
|
_reconfigure_syslog(level)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
# PyInstaller spec for hbc_windows.exe
|
||||||
|
# Build with: pyinstaller hbc_windows.spec
|
||||||
|
#
|
||||||
|
# Requirements (on Windows):
|
||||||
|
# pip install pyinstaller
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['hbc_windows.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['tkinter', 'unittest', 'email', 'html', 'http', 'urllib', 'xml'],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zlib_archive, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='hbc_windows',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=None,
|
||||||
|
version=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Install hbc_windows.exe as a Windows Service using NSSM.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Installs the HeartBeat Client as a Windows Service that starts automatically.
|
||||||
|
Requires NSSM (Non-Sucking Service Manager) in PATH or alongside this script.
|
||||||
|
Requires hbc_windows.exe built via: pyinstaller hbc_windows.spec
|
||||||
|
|
||||||
|
.PARAMETER Server
|
||||||
|
HBD server hostname or IP address (required).
|
||||||
|
|
||||||
|
.PARAMETER ExePath
|
||||||
|
Path to hbc_windows.exe. Defaults to the directory containing this script.
|
||||||
|
|
||||||
|
.PARAMETER ServiceName
|
||||||
|
Windows service name. Default: heartbeat-client
|
||||||
|
|
||||||
|
.PARAMETER ConfigFile
|
||||||
|
Path to hbc.json config file. Optional.
|
||||||
|
|
||||||
|
.PARAMETER LogFile
|
||||||
|
Path to log file. Default: C:\ProgramData\heartbeat\hbc.log
|
||||||
|
|
||||||
|
.PARAMETER Interval
|
||||||
|
Heartbeat interval in seconds. Default: 10
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\install_hbc_windows.ps1 -Server hbd.example.com
|
||||||
|
.\install_hbc_windows.ps1 -Server hbd.example.com -ConfigFile C:\ProgramData\heartbeat\hbc.json
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Server,
|
||||||
|
|
||||||
|
[string]$ExePath = "",
|
||||||
|
[string]$ServiceName = "heartbeat-client",
|
||||||
|
[string]$ConfigFile = "",
|
||||||
|
[string]$LogFile = "C:\ProgramData\heartbeat\hbc.log",
|
||||||
|
[int]$Interval = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Locate hbc_windows.exe
|
||||||
|
if ($ExePath -eq "") {
|
||||||
|
$ExePath = Join-Path $PSScriptRoot "hbc_windows.exe"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $ExePath)) {
|
||||||
|
Write-Error "hbc_windows.exe not found at: $ExePath`nBuild it first with: pyinstaller hbc_windows.spec"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locate NSSM
|
||||||
|
$nssm = Get-Command nssm -ErrorAction SilentlyContinue
|
||||||
|
if (-not $nssm) {
|
||||||
|
$nssmLocal = Join-Path $PSScriptRoot "nssm.exe"
|
||||||
|
if (Test-Path $nssmLocal) {
|
||||||
|
$nssm = $nssmLocal
|
||||||
|
} else {
|
||||||
|
Write-Error "nssm.exe not found in PATH or alongside this script.`nDownload from https://nssm.cc/download"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$nssm = $nssm.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build argument list
|
||||||
|
$args_list = "--daemon $Server"
|
||||||
|
if ($ConfigFile -ne "") {
|
||||||
|
$args_list = "--daemon -c `"$ConfigFile`" $Server"
|
||||||
|
}
|
||||||
|
if ($LogFile -ne "") {
|
||||||
|
$args_list = "$args_list --log-file `"$LogFile`""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
$dataDir = "C:\ProgramData\heartbeat"
|
||||||
|
if (-not (Test-Path $dataDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dataDir | Out-Null
|
||||||
|
Write-Host "Created $dataDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove existing service if present
|
||||||
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host "Removing existing service '$ServiceName'..."
|
||||||
|
& $nssm stop $ServiceName 2>$null
|
||||||
|
& $nssm remove $ServiceName confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install service
|
||||||
|
Write-Host "Installing service '$ServiceName'..."
|
||||||
|
& $nssm install $ServiceName $ExePath $args_list
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "nssm install failed (exit $LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure service
|
||||||
|
& $nssm set $ServiceName DisplayName "HeartBeat Client"
|
||||||
|
& $nssm set $ServiceName Description "Sends heartbeat and plugin metrics to the HBD monitoring server."
|
||||||
|
& $nssm set $ServiceName Start SERVICE_AUTO_START
|
||||||
|
& $nssm set $ServiceName AppStdout (Join-Path $dataDir "nssm_stdout.log")
|
||||||
|
& $nssm set $ServiceName AppStderr (Join-Path $dataDir "nssm_stderr.log")
|
||||||
|
& $nssm set $ServiceName AppRotateFiles 1
|
||||||
|
& $nssm set $ServiceName AppRotateBytes 5242880
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
Write-Host "Starting service '$ServiceName'..."
|
||||||
|
& $nssm start $ServiceName
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning "Service installed but failed to start — check logs in $dataDir"
|
||||||
|
} else {
|
||||||
|
Write-Host "Service '$ServiceName' started successfully."
|
||||||
|
Write-Host "Log file: $LogFile"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Useful commands:"
|
||||||
|
Write-Host " nssm status $ServiceName"
|
||||||
|
Write-Host " nssm stop $ServiceName"
|
||||||
|
Write-Host " nssm restart $ServiceName"
|
||||||
|
Write-Host " nssm remove $ServiceName confirm"
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for the allow_remote_command gate on CMD packets in hbc."""
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hbd.client import main as client_main
|
||||||
|
from hbd.client.config import CLIENT_DEFAULTS, load_config
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
"""Stand-in for AsyncConnection that records what the client sends back."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def sendto(self, msg, msg_id="HTB"):
|
||||||
|
self.sent.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(monkeypatch):
|
||||||
|
"""A fake connection with remote commands disabled and subprocess booby-trapped."""
|
||||||
|
monkeypatch.setattr(client_main, "allow_remote_command", False)
|
||||||
|
|
||||||
|
def explode(*a, **kw):
|
||||||
|
raise AssertionError("subprocess must not run when the command is refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "check_output", explode)
|
||||||
|
return FakeConn()
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_is_false():
|
||||||
|
assert CLIENT_DEFAULTS["allow_remote_command"] is False
|
||||||
|
assert load_config("/nonexistent/hbc.yaml")["allow_remote_command"] is False
|
||||||
|
assert client_main.allow_remote_command is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_when_disabled(conn):
|
||||||
|
asyncio.run(client_main.handle_command(conn, {"cmd": "id"}))
|
||||||
|
assert conn.sent == [{
|
||||||
|
"service": "command",
|
||||||
|
"msg": "Refused: allow_remote_command is false",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_reported_for_every_attempt(conn):
|
||||||
|
for _ in range(3):
|
||||||
|
asyncio.run(client_main.handle_command(conn, {"cmd": "rm -rf /"}))
|
||||||
|
assert len(conn.sent) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_command_is_ignored_either_way(conn, monkeypatch):
|
||||||
|
asyncio.run(client_main.handle_command(conn, {}))
|
||||||
|
monkeypatch.setattr(client_main, "allow_remote_command", True)
|
||||||
|
asyncio.run(client_main.handle_command(conn, {"cmd": ""}))
|
||||||
|
assert conn.sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_executed_when_enabled(conn, monkeypatch):
|
||||||
|
monkeypatch.setattr(client_main, "allow_remote_command", True)
|
||||||
|
monkeypatch.setattr(subprocess, "check_output", lambda *a, **kw: b"uid=0(root)")
|
||||||
|
asyncio.run(client_main.handle_command(conn, {"cmd": "id"}))
|
||||||
|
assert conn.sent == [{"service": "command", "msg": "OK uid=0(root)"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_reported_when_enabled(conn, monkeypatch):
|
||||||
|
monkeypatch.setattr(client_main, "allow_remote_command", True)
|
||||||
|
|
||||||
|
def fail(*a, **kw):
|
||||||
|
raise subprocess.CalledProcessError(1, "false")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "check_output", fail)
|
||||||
|
asyncio.run(client_main.handle_command(conn, {"cmd": "false"}))
|
||||||
|
assert len(conn.sent) == 1
|
||||||
|
assert conn.sent[0]["msg"].startswith("CalledProcessError ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_file_can_opt_in(tmp_path):
|
||||||
|
cfg_file = tmp_path / "hbc.yaml"
|
||||||
|
cfg_file.write_text("allow_remote_command: true\n", encoding="utf-8")
|
||||||
|
assert load_config(str(cfg_file))["allow_remote_command"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("configured, expected", [(True, True), (False, False), (None, False)])
|
||||||
|
def test_async_main_sets_the_flag_from_config(monkeypatch, configured, expected):
|
||||||
|
"""async_main must apply the config value before any CMD packet can arrive."""
|
||||||
|
monkeypatch.setattr(client_main, "allow_remote_command", not expected)
|
||||||
|
|
||||||
|
class Bail(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# gethostname() is the first call after the flag is assigned — bail there so the
|
||||||
|
# test never opens a socket.
|
||||||
|
def bail():
|
||||||
|
raise Bail
|
||||||
|
|
||||||
|
monkeypatch.setattr(client_main.socket, "gethostname", bail)
|
||||||
|
|
||||||
|
cfg = {} if configured is None else {"allow_remote_command": configured}
|
||||||
|
with pytest.raises(Bail):
|
||||||
|
asyncio.run(client_main.async_main(object(), cfg))
|
||||||
|
assert client_main.allow_remote_command is expected
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Tests for ownership rules and scoped config merges (hbd.server.config_access)."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import config_access as ca
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Visibility helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_is_global_when_no_owner():
|
||||||
|
assert ca.is_global({"type": "pushover"})
|
||||||
|
assert ca.is_global({"type": "email", "owner": ""})
|
||||||
|
assert ca.is_global(None) # non-dict is treated as global
|
||||||
|
assert not ca.is_global({"type": "email", "owner": "alice"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_can_use_global_or_own():
|
||||||
|
assert ca.user_can_use({"type": "pushover"}, "alice")
|
||||||
|
assert ca.user_can_use({"owner": "alice"}, "alice")
|
||||||
|
assert not ca.user_can_use({"owner": "bob"}, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
HOSTS = {
|
||||||
|
"web1": {"owner": "alice", "watch": True},
|
||||||
|
"web2": {"owner": "bob", "managers": ["alice"]},
|
||||||
|
"web3": {"owner": "bob", "managers": "carol"}, # string manager form
|
||||||
|
"web4": {"watch": True}, # unowned
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_hosts_owner_and_manager():
|
||||||
|
assert set(ca.user_hosts(HOSTS, "alice")) == {"web1", "web2"}
|
||||||
|
assert set(ca.user_hosts(HOSTS, "carol")) == {"web3"}
|
||||||
|
assert ca.user_hosts(HOSTS, "dave") == {}
|
||||||
|
assert ca.user_hosts(None, "alice") == {}
|
||||||
|
|
||||||
|
|
||||||
|
CHANNELS = {
|
||||||
|
"global_ch": {"type": "pushover"},
|
||||||
|
"alice_ch": {"type": "email", "owner": "alice"},
|
||||||
|
"bob_ch": {"type": "signal", "owner": "bob"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_channels_global_plus_own():
|
||||||
|
assert set(ca.user_channels(CHANNELS, "alice")) == {"global_ch", "alice_ch"}
|
||||||
|
assert set(ca.user_channels(CHANNELS, "dave")) == {"global_ch"}
|
||||||
|
|
||||||
|
|
||||||
|
TCS = {
|
||||||
|
"default": {"thresholds": {}},
|
||||||
|
"alice_tc": {"owner": "alice", "thresholds": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_threshold_configs_global_plus_own():
|
||||||
|
assert set(ca.user_threshold_configs(TCS, "alice")) == {"default", "alice_tc"}
|
||||||
|
assert set(ca.user_threshold_configs(TCS, "bob")) == {"default"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# merge_hosts_scoped
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MERGE_HOSTS = {
|
||||||
|
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
|
||||||
|
"managed": {"owner": "bob", "managers": ["alice"], "watch": True,
|
||||||
|
"notification_channels": ["bob_ch"]},
|
||||||
|
"foreign": {"owner": "bob", "watch": False},
|
||||||
|
}
|
||||||
|
MERGE_CHANNELS = {
|
||||||
|
"global_ch": {"type": "pushover"},
|
||||||
|
"alice_ch": {"type": "email", "owner": "alice"},
|
||||||
|
"bob_ch": {"type": "signal", "owner": "bob"},
|
||||||
|
}
|
||||||
|
MERGE_TCS = {"alice_tc": {"owner": "alice"}, "bob_tc": {"owner": "bob"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_hosts(payload, existing=None):
|
||||||
|
return ca.merge_hosts_scoped(
|
||||||
|
existing if existing is not None else dict(MERGE_HOSTS),
|
||||||
|
payload, "alice", MERGE_CHANNELS, MERGE_TCS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_payload(**overrides):
|
||||||
|
"""Payload covering alice's full visible subset, with per-host overrides."""
|
||||||
|
p = {
|
||||||
|
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
|
||||||
|
"managed": {"watch": True, "notification_channels": ["bob_ch"]},
|
||||||
|
}
|
||||||
|
p.update(overrides)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_preserves_foreign_hosts():
|
||||||
|
result = _merge_hosts(_full_payload())
|
||||||
|
assert result["foreign"] == {"owner": "bob", "watch": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_edits_settings():
|
||||||
|
result = _merge_hosts(_full_payload(managed={"watch": False, "dyndns": True,
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
assert result["managed"]["watch"] is False
|
||||||
|
assert result["managed"]["dyndns"] is True
|
||||||
|
# access fields carried over untouched
|
||||||
|
assert result["managed"]["owner"] == "bob"
|
||||||
|
assert result["managed"]["managers"] == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_change_owner():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managed"):
|
||||||
|
_merge_hosts(_full_payload(managed={"watch": True, "owner": "alice"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_change_managers():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managers"):
|
||||||
|
_merge_hosts(_full_payload(managed={"watch": True, "managers": ["alice", "carol"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_same_access_values_ok():
|
||||||
|
# Submitting unchanged access fields is not a violation.
|
||||||
|
result = _merge_hosts(_full_payload(managed={"watch": True, "owner": "bob",
|
||||||
|
"managers": ["alice"],
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
assert result["managed"]["owner"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_delete():
|
||||||
|
payload = {"mine": {"owner": "alice", "watch": True}} # 'managed' missing
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managed"):
|
||||||
|
_merge_hosts(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_can_delete():
|
||||||
|
payload = _full_payload()
|
||||||
|
del payload["mine"]
|
||||||
|
result = _merge_hosts(payload)
|
||||||
|
assert "mine" not in result
|
||||||
|
assert "managed" in result and "foreign" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_can_transfer_ownership():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "bob", "watch": True}))
|
||||||
|
assert result["mine"]["owner"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_new_host_owner_forced():
|
||||||
|
result = _merge_hosts(_full_payload(newhost={"watch": True, "owner": "bob"}))
|
||||||
|
assert result["newhost"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_cannot_touch_foreign_host():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="foreign"):
|
||||||
|
_merge_hosts(_full_payload(foreign={"watch": True}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_cannot_add_foreign_private_channel():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_ch"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_keeps_preexisting_foreign_assignment():
|
||||||
|
# 'managed' already has bob_ch; keeping it is fine.
|
||||||
|
result = _merge_hosts(_full_payload())
|
||||||
|
assert result["managed"]["notification_channels"] == ["bob_ch"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_can_add_global_and_own_channel():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"notification_channels": ["global_ch", "alice_ch"]}))
|
||||||
|
assert result["mine"]["notification_channels"] == ["global_ch", "alice_ch"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_threshold_assignment_validated():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"threshold_config": ["bob_tc"]}))
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"threshold_config": ["default", "alice_tc"]}))
|
||||||
|
assert result["mine"]["threshold_config"] == ["default", "alice_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_rejects_unknown_fields():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="sneaky"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True, "sneaky": 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_clearing_list_removes_key():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True}))
|
||||||
|
assert "notification_channels" not in result["mine"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# merge_threshold_configs_scoped
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MERGE_EXISTING_TCS = {
|
||||||
|
"default": {"thresholds": {"cpu": {"load": {"warning": 2}}}},
|
||||||
|
"alice_tc": {"owner": "alice", "thresholds": {"cpu": {"load": {"warning": 3}}}},
|
||||||
|
"bob_tc": {"owner": "bob", "thresholds": {}},
|
||||||
|
"global_tc": {"thresholds": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tcs(payload):
|
||||||
|
return ca.merge_threshold_configs_scoped(dict(MERGE_EXISTING_TCS), payload, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_new_config_owner_forced():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {}},
|
||||||
|
"new_tc": {"thresholds": {}, "owner": "bob"}})
|
||||||
|
assert result["new_tc"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_edit_own():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {"mem": {"used": {"warning": 90}}}}})
|
||||||
|
assert result["alice_tc"]["thresholds"] == {"mem": {"used": {"warning": 90}}}
|
||||||
|
assert result["alice_tc"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_delete_own_when_missing():
|
||||||
|
result = _merge_tcs({})
|
||||||
|
assert "alice_tc" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_preserves_global_and_foreign():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {}}})
|
||||||
|
assert result["default"] == MERGE_EXISTING_TCS["default"]
|
||||||
|
assert result["bob_tc"] == MERGE_EXISTING_TCS["bob_tc"]
|
||||||
|
assert result["global_tc"] == MERGE_EXISTING_TCS["global_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_default():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="default"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "default": {"thresholds": {}}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_global_name_collision():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="global_tc"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "global_tc": {"thresholds": {}}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_foreign_owned():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "bob_tc": {"thresholds": {}}})
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Tests for the dedicated events journal (write path and read path)."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from hbd.server import journal
|
||||||
|
|
||||||
|
|
||||||
|
def _make_journal(tmp_path, **overrides):
|
||||||
|
cfg = {"journal_dir": str(tmp_path), "journal_file": "events.journal"}
|
||||||
|
cfg.update(overrides)
|
||||||
|
j = journal.MessageJournal(cfg)
|
||||||
|
assert asyncio.run(j.initialize())
|
||||||
|
return j
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lines(tmp_path, name="events.journal"):
|
||||||
|
return (tmp_path / name).read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
EV1 = {"ts": 1000.0, "host": "h1", "level": "INFO", "service": None, "message": "host up"}
|
||||||
|
EV2 = {"ts": 2000.0, "host": "h2", "level": "CRITICAL", "service": "cpu", "message": "cpu high"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_event_writes_one_json_line(tmp_path):
|
||||||
|
j = _make_journal(tmp_path)
|
||||||
|
asyncio.run(j.log_event(EV1))
|
||||||
|
asyncio.run(j.close())
|
||||||
|
lines = _read_lines(tmp_path)
|
||||||
|
assert len(lines) == 1
|
||||||
|
assert json.loads(lines[0]) == EV1
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_event_appends_in_order(tmp_path):
|
||||||
|
j = _make_journal(tmp_path)
|
||||||
|
asyncio.run(j.log_event(EV1))
|
||||||
|
asyncio.run(j.log_event(EV2))
|
||||||
|
asyncio.run(j.close())
|
||||||
|
lines = _read_lines(tmp_path)
|
||||||
|
assert [json.loads(ln)["ts"] for ln in lines] == [1000.0, 2000.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_event_rotates_at_max_size(tmp_path):
|
||||||
|
# max_size fits one serialized event line (~77 bytes) but not two, so the
|
||||||
|
# second write triggers exactly one rotation
|
||||||
|
j = _make_journal(tmp_path, journal_max_size=120)
|
||||||
|
asyncio.run(j.log_event(EV1))
|
||||||
|
asyncio.run(j.log_event(EV2))
|
||||||
|
asyncio.run(j.close())
|
||||||
|
backups = list(tmp_path.glob("events.journal.*"))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert json.loads(backups[0].read_text().splitlines()[0]) == EV1
|
||||||
|
assert json.loads(_read_lines(tmp_path)[0]) == EV2
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_event_noop_when_disabled(tmp_path):
|
||||||
|
j = journal.MessageJournal(
|
||||||
|
{"journal_dir": str(tmp_path), "journal_file": "events.journal", "journal_enabled": False}
|
||||||
|
)
|
||||||
|
asyncio.run(j.initialize())
|
||||||
|
asyncio.run(j.log_event(EV1))
|
||||||
|
assert not (tmp_path / "events.journal").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_seeds_empty_journal(tmp_path):
|
||||||
|
j = _make_journal(tmp_path)
|
||||||
|
asyncio.run(j.backfill([EV1, EV2]))
|
||||||
|
asyncio.run(j.close())
|
||||||
|
lines = _read_lines(tmp_path)
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert json.loads(lines[0]) == EV1
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_skipped_when_journal_nonempty(tmp_path):
|
||||||
|
(tmp_path / "events.journal").write_text(json.dumps(EV1) + "\n")
|
||||||
|
j = _make_journal(tmp_path) # initialize() picks up the existing size
|
||||||
|
asyncio.run(j.backfill([EV2]))
|
||||||
|
asyncio.run(j.close())
|
||||||
|
assert len(_read_lines(tmp_path)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_journal_uses_events_config_keys(tmp_path):
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
try:
|
||||||
|
ej = journal.get_events_journal(
|
||||||
|
{
|
||||||
|
"journal_dir": str(tmp_path),
|
||||||
|
"events_journal_file": "ev.jsonl",
|
||||||
|
"events_journal_max_size": 12345,
|
||||||
|
"events_journal_max_backups": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert ej.journal_file == "ev.jsonl"
|
||||||
|
assert ej.max_size == 12345
|
||||||
|
assert ej.max_backups == 3
|
||||||
|
assert ej.journal_dir == tmp_path
|
||||||
|
# singleton: second call returns the same instance
|
||||||
|
assert journal.get_events_journal() is ej
|
||||||
|
finally:
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_journal_defaults(tmp_path):
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
try:
|
||||||
|
ej = journal.get_events_journal({"journal_dir": str(tmp_path)})
|
||||||
|
assert ej.journal_file == "events.journal"
|
||||||
|
assert ej.max_size == 10 * 1024 * 1024
|
||||||
|
assert ej.max_backups == 10
|
||||||
|
finally:
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- read path -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _write_journal(path, events):
|
||||||
|
path.write_text("".join(json.dumps(e) + "\n" for e in events), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _evts(*ts_list):
|
||||||
|
return [
|
||||||
|
{"ts": float(t), "host": f"host{i}", "level": "INFO", "service": None, "message": f"msg {t}"}
|
||||||
|
for i, t in enumerate(ts_list)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_newest_first(tmp_path):
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(1, 2, 3))
|
||||||
|
events, more = journal.read_events(tmp_path)
|
||||||
|
assert [e["ts"] for e in events] == [3.0, 2.0, 1.0]
|
||||||
|
assert more is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_limit_and_more(tmp_path):
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(1, 2, 3))
|
||||||
|
events, more = journal.read_events(tmp_path, limit=2)
|
||||||
|
assert [e["ts"] for e in events] == [3.0, 2.0]
|
||||||
|
assert more is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_before_cursor(tmp_path):
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(1, 2, 3))
|
||||||
|
events, _ = journal.read_events(tmp_path, before=3.0)
|
||||||
|
assert [e["ts"] for e in events] == [2.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_spans_rotated_files(tmp_path):
|
||||||
|
# rotated backup holds the oldest events; current file the newest
|
||||||
|
_write_journal(tmp_path / "events.journal.20260101-000000", _evts(1, 2))
|
||||||
|
_write_journal(tmp_path / "events.journal.20260201-000000", _evts(3, 4))
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(5, 6))
|
||||||
|
events, more = journal.read_events(tmp_path, limit=10)
|
||||||
|
assert [e["ts"] for e in events] == [6.0, 5.0, 4.0, 3.0, 2.0, 1.0]
|
||||||
|
assert more is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_pagination_across_files(tmp_path):
|
||||||
|
_write_journal(tmp_path / "events.journal.20260101-000000", _evts(1, 2))
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(3, 4))
|
||||||
|
page1, more1 = journal.read_events(tmp_path, limit=3)
|
||||||
|
assert [e["ts"] for e in page1] == [4.0, 3.0, 2.0]
|
||||||
|
assert more1 is True
|
||||||
|
page2, more2 = journal.read_events(tmp_path, limit=3, before=page1[-1]["ts"])
|
||||||
|
assert [e["ts"] for e in page2] == [1.0]
|
||||||
|
assert more2 is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_host_filter_substring_case_insensitive(tmp_path):
|
||||||
|
evs = [
|
||||||
|
{"ts": 1.0, "host": "Wentworth", "level": "INFO", "service": None, "message": "a"},
|
||||||
|
{"ts": 2.0, "host": "winter", "level": "INFO", "service": None, "message": "b"},
|
||||||
|
]
|
||||||
|
_write_journal(tmp_path / "events.journal", evs)
|
||||||
|
events, _ = journal.read_events(tmp_path, host="went")
|
||||||
|
assert [e["host"] for e in events] == ["Wentworth"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_level_filter_exact_case_insensitive(tmp_path):
|
||||||
|
evs = [
|
||||||
|
{"ts": 1.0, "host": "h", "level": "CRITICAL", "service": None, "message": "a"},
|
||||||
|
{"ts": 2.0, "host": "h", "level": "INFO", "service": None, "message": "b"},
|
||||||
|
]
|
||||||
|
_write_journal(tmp_path / "events.journal", evs)
|
||||||
|
events, _ = journal.read_events(tmp_path, level="critical")
|
||||||
|
assert [e["level"] for e in events] == ["CRITICAL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_message_filter(tmp_path):
|
||||||
|
evs = [
|
||||||
|
{"ts": 1.0, "host": "h", "level": "INFO", "service": None, "message": "disk almost full"},
|
||||||
|
{"ts": 2.0, "host": "h", "level": "INFO", "service": None, "message": "all quiet"},
|
||||||
|
]
|
||||||
|
_write_journal(tmp_path / "events.journal", evs)
|
||||||
|
events, _ = journal.read_events(tmp_path, q="Disk")
|
||||||
|
assert [e["ts"] for e in events] == [1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_skips_malformed_lines(tmp_path):
|
||||||
|
p = tmp_path / "events.journal"
|
||||||
|
p.write_text('{"ts": 1.0, "host": "h", "level": "INFO", "message": "ok"}\nnot json\n[1,2]\n')
|
||||||
|
events, _ = journal.read_events(tmp_path)
|
||||||
|
assert [e["ts"] for e in events] == [1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_predicate(tmp_path):
|
||||||
|
_write_journal(tmp_path / "events.journal", _evts(1, 2))
|
||||||
|
events, _ = journal.read_events(tmp_path, predicate=lambda e: e["host"] == "host0")
|
||||||
|
assert [e["host"] for e in events] == ["host0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_events_missing_dir(tmp_path):
|
||||||
|
events, more = journal.read_events(tmp_path / "nope")
|
||||||
|
assert events == [] and more is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_events_over_in_memory_ring():
|
||||||
|
ring = _evts(1, 2, 3) # oldest-first, like data.msgs
|
||||||
|
events, more = journal.filter_events(reversed(ring), limit=2)
|
||||||
|
assert [e["ts"] for e in events] == [3.0, 2.0]
|
||||||
|
assert more is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_events_skips_non_dict_entries():
|
||||||
|
ring = [
|
||||||
|
{"ts": 1.0, "host": "h", "level": "INFO", "service": None, "message": "a"},
|
||||||
|
"legacy string entry",
|
||||||
|
None,
|
||||||
|
{"ts": 2.0, "host": "h", "level": "INFO", "service": None, "message": "b"},
|
||||||
|
]
|
||||||
|
events, more = journal.filter_events(reversed(ring), limit=10)
|
||||||
|
assert [e["ts"] for e in events] == [2.0, 1.0]
|
||||||
|
assert more is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- eventlog wiring --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_eventlog_writes_to_events_journal(tmp_path):
|
||||||
|
from hbd.server import data, notify
|
||||||
|
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
saved_msgs = data.msgs
|
||||||
|
data.msgs = []
|
||||||
|
try:
|
||||||
|
ej = journal.get_events_journal({"journal_dir": str(tmp_path)})
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
await ej.initialize()
|
||||||
|
notify.setup({}, loop=asyncio.get_running_loop())
|
||||||
|
notify.eventlog("h1", "INFO", "hello world")
|
||||||
|
await asyncio.sleep(0.05) # let the scheduled journal write run
|
||||||
|
await ej.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
lines = _read_lines(tmp_path)
|
||||||
|
assert len(lines) == 1
|
||||||
|
ev = json.loads(lines[0])
|
||||||
|
assert ev["host"] == "h1"
|
||||||
|
assert ev["level"] == "INFO"
|
||||||
|
assert ev["message"] == "hello world"
|
||||||
|
assert isinstance(ev["ts"], float)
|
||||||
|
finally:
|
||||||
|
journal._events_journal_instance = None
|
||||||
|
data.msgs = saved_msgs
|
||||||
|
notify._loop = None
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Tests for flap detection (hbd.server.flap) and its hook in notify.send_notification."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hbd.server import flap, hbdclass, notify, users as users_mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_flap_state():
|
||||||
|
"""Every test starts with an empty state and a 3-in-10-minutes config."""
|
||||||
|
flap._state.clear()
|
||||||
|
flap.setup({"flap_count": 3, "flap_interval": 10})
|
||||||
|
yield
|
||||||
|
flap._state.clear()
|
||||||
|
flap.setup({})
|
||||||
|
|
||||||
|
|
||||||
|
def alerts(n, host="h1", service="cpu", level="CRITICAL"):
|
||||||
|
return [flap.observe(host, service, level) for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def advance(seconds, host="h1", service="cpu"):
|
||||||
|
"""Backdate a key's recorded timestamps to simulate the clock moving on."""
|
||||||
|
st = flap._state[(host, service)]
|
||||||
|
st["events"] = [t - seconds for t in st["events"]]
|
||||||
|
if st["ok_since"] is not None:
|
||||||
|
st["ok_since"] -= seconds
|
||||||
|
|
||||||
|
|
||||||
|
# --- tripping ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_alerts_up_to_flap_count_pass():
|
||||||
|
assert alerts(3) == [flap.PASS] * 3
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_exceeding_flap_count_trips_then_suppresses():
|
||||||
|
assert alerts(5) == [flap.PASS, flap.PASS, flap.PASS, flap.TRIP, flap.SUPPRESS]
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_and_critical_both_count():
|
||||||
|
flap.observe("h1", "cpu", "WARNING")
|
||||||
|
flap.observe("h1", "cpu", "CRITICAL")
|
||||||
|
flap.observe("h1", "cpu", "WARNING")
|
||||||
|
assert flap.observe("h1", "cpu", "CRITICAL") == flap.TRIP
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_outside_the_window_do_not_count():
|
||||||
|
alerts(3)
|
||||||
|
advance(11 * 60) # older than flap_interval
|
||||||
|
assert alerts(3) == [flap.PASS] * 3
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_alone_never_trips():
|
||||||
|
assert [flap.observe("h1", "cpu", "RECOVER") for _ in range(5)] == [flap.PASS] * 5
|
||||||
|
|
||||||
|
|
||||||
|
# --- suppression while flapping --------------------------------------------
|
||||||
|
|
||||||
|
def test_recover_and_info_are_suppressed_while_flapping():
|
||||||
|
alerts(4)
|
||||||
|
assert flap.observe("h1", "cpu", "RECOVER") == flap.SUPPRESS
|
||||||
|
assert flap.observe("h1", "cpu", "INFO") == flap.SUPPRESS
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_passes_when_not_flapping():
|
||||||
|
assert flap.observe("h1", "cpu", "INFO") == flap.PASS
|
||||||
|
|
||||||
|
|
||||||
|
# --- clearing ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clears_silently_one_interval_after_recover():
|
||||||
|
alerts(4)
|
||||||
|
flap.observe("h1", "cpu", "RECOVER")
|
||||||
|
advance(10 * 60)
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
assert flap.observe("h1", "cpu", "CRITICAL") == flap.PASS
|
||||||
|
|
||||||
|
|
||||||
|
def test_still_flapping_before_the_interval_elapses():
|
||||||
|
alerts(4)
|
||||||
|
flap.observe("h1", "cpu", "RECOVER")
|
||||||
|
advance(9 * 60)
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
assert flap.observe("h1", "cpu", "CRITICAL") == flap.SUPPRESS
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_during_the_quiet_window_keeps_it_flapping():
|
||||||
|
alerts(4)
|
||||||
|
flap.observe("h1", "cpu", "RECOVER")
|
||||||
|
advance(9 * 60)
|
||||||
|
flap.observe("h1", "cpu", "CRITICAL") # resets the quiet window
|
||||||
|
advance(2 * 60)
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- host removal ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clear_host_drops_flapping_state_even_without_a_recover():
|
||||||
|
# A host dropped mid-flap (no RECOVER ever received) must not stay
|
||||||
|
# flapping forever: nothing will ever set ok_since for it again.
|
||||||
|
alerts(4)
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
flap.clear_host("h1")
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
assert flap.observe("h1", "cpu", "CRITICAL") == flap.PASS
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_host_only_affects_the_named_host():
|
||||||
|
alerts(4, host="h1")
|
||||||
|
alerts(4, host="h2")
|
||||||
|
flap.clear_host("h1")
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
assert flap.flapping_services("h2") == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- keying -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_services_and_hosts_are_tracked_independently():
|
||||||
|
alerts(4, service="cpu")
|
||||||
|
assert flap.observe("h1", "disk", "CRITICAL") == flap.PASS
|
||||||
|
assert flap.observe("h2", "cpu", "CRITICAL") == flap.PASS
|
||||||
|
assert flap.flapping_services("h1") == ["cpu"]
|
||||||
|
assert flap.flapping_services("h2") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_level_events_use_the_empty_service_key():
|
||||||
|
alerts(4, service="")
|
||||||
|
assert flap.flapping_services("h1") == [""]
|
||||||
|
|
||||||
|
|
||||||
|
# --- disabled ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [
|
||||||
|
{"flap_count": 0, "flap_interval": 10},
|
||||||
|
{"flap_count": 3, "flap_interval": 0},
|
||||||
|
{},
|
||||||
|
])
|
||||||
|
def test_disabled_never_suppresses(cfg):
|
||||||
|
flap.setup(cfg)
|
||||||
|
assert alerts(20) == [flap.PASS] * 20
|
||||||
|
assert flap.flapping_services("h1") == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- integration through the real dispatch path -----------------------------
|
||||||
|
|
||||||
|
NOTIFY_CFG = {
|
||||||
|
"flap_count": 3,
|
||||||
|
"flap_interval": 10,
|
||||||
|
"notification_channels": {"ch1": {"type": "pushover", "token": "t", "user": "u"}},
|
||||||
|
"users": {"alice": {"notification_channels": ["ch1"]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def delivered(monkeypatch):
|
||||||
|
"""Wire up a host, a user and a stub channel driver; collect what gets delivered."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(notify, "_config", dict(notify._config)) # restored on teardown
|
||||||
|
notify.setup(NOTIFY_CFG)
|
||||||
|
users_mod.load_users(NOTIFY_CFG)
|
||||||
|
monkeypatch.setitem(notify._DRIVERS, "pushover", lambda cfg, n: sent.append(n.body) or True)
|
||||||
|
host = hbdclass.Host("flaphost")
|
||||||
|
host.watched = True
|
||||||
|
host.owner = "alice"
|
||||||
|
yield sent, host
|
||||||
|
hbdclass.Host.hosts.pop("flaphost", None)
|
||||||
|
users_mod.load_users({})
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(level, service, body):
|
||||||
|
n = notify.Notification(title=f"[{level}] flaphost", body=body, level=level, service=service)
|
||||||
|
asyncio.run(notify.send_notification("flaphost", n))
|
||||||
|
|
||||||
|
|
||||||
|
def test_marker_on_the_tripping_notification_then_nothing(delivered):
|
||||||
|
sent, host = delivered
|
||||||
|
for i in range(5):
|
||||||
|
_notify("CRITICAL", "cpu", f"cpu = 9{i}")
|
||||||
|
_notify("RECOVER", "cpu", "cpu = 12")
|
||||||
|
|
||||||
|
assert sent == ["cpu = 90", "cpu = 91", "cpu = 92", f"cpu = 93 {flap.FLAP_MARKER}"]
|
||||||
|
assert host.stateinfo()["flapping"] == ["cpu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flapping_service_does_not_silence_another(delivered):
|
||||||
|
sent, host = delivered
|
||||||
|
for i in range(5):
|
||||||
|
_notify("CRITICAL", "cpu", "cpu = 99")
|
||||||
|
sent.clear()
|
||||||
|
_notify("CRITICAL", "disk", "disk = 91")
|
||||||
|
assert sent == ["disk = 91"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_resume_after_the_state_clears(delivered):
|
||||||
|
sent, host = delivered
|
||||||
|
for i in range(5):
|
||||||
|
_notify("CRITICAL", "cpu", "cpu = 99")
|
||||||
|
_notify("RECOVER", "cpu", "cpu = 12")
|
||||||
|
advance(10 * 60, host="flaphost")
|
||||||
|
assert host.stateinfo()["flapping"] == []
|
||||||
|
sent.clear()
|
||||||
|
_notify("CRITICAL", "cpu", "cpu = 99")
|
||||||
|
assert sent == ["cpu = 99"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stateinfo_flapping_survives_json_encoding(delivered):
|
||||||
|
sent, host = delivered
|
||||||
|
for i in range(5):
|
||||||
|
_notify("CRITICAL", "", "IPv4 overdue") # host-level, empty service key
|
||||||
|
info = host.stateinfo()
|
||||||
|
assert info["flapping"] == [""]
|
||||||
|
assert json.loads(json.dumps(info))["flapping"] == [""]
|
||||||
@@ -171,3 +171,28 @@ def test_write_path_preserves_oauth_client_secret(tmp_path):
|
|||||||
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
||||||
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- threshold form payload shape ----
|
||||||
|
|
||||||
|
def test_build_threshold_configs_new_shape_with_owner():
|
||||||
|
form = {
|
||||||
|
"servers": {
|
||||||
|
"owner": "alice",
|
||||||
|
"metrics": {"cpu_monitor.load_15min": {"operator": ">", "warning": 4.0,
|
||||||
|
"critical": 8.0, "enabled": True}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = http._build_threshold_configs_from_form(form)
|
||||||
|
assert result["servers"]["owner"] == "alice"
|
||||||
|
assert result["servers"]["thresholds"]["cpu_monitor"]["load_15min"]["warning"] == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_threshold_configs_empty_owner_means_global():
|
||||||
|
form = {"servers": {"owner": "", "metrics": {"rtt": {"warning": 100.0}}}}
|
||||||
|
result = http._build_threshold_configs_from_form(form)
|
||||||
|
assert "owner" not in result["servers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_threshold_configs_ignores_entries_without_metrics():
|
||||||
|
assert http._build_threshold_configs_from_form({"bad": {"owner": "x"}}) == {}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for _build_host_info helper in http.py."""
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from hbd.server.http import _build_host_info
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
@@ -172,3 +171,42 @@ def test_build_host_info_covers_empty_when_exact_matches_only():
|
|||||||
result = _build_host_info(host, threshold_checker=checker)
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
t = result["thresholds"][0]
|
t = result["thresholds"][0]
|
||||||
assert t["covers"] == []
|
assert t["covers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConnFull:
|
||||||
|
def __init__(self, afam, addr, state, rtt, statetime, lastbeat, rtts=None):
|
||||||
|
self.afam = afam
|
||||||
|
self.addr = addr
|
||||||
|
self.state = state
|
||||||
|
self.rtts = rtts if rtts is not None else [rtt]
|
||||||
|
self.statetime = statetime
|
||||||
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_includes_connections():
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConnFull("IPv4", "10.0.0.5", "up", 14.2, 1000.0, 2000.0),
|
||||||
|
"IPv6": _FakeConnFull("IPv6", "fd00::5", "overdue", 0, 1500.0, 1900.0),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
conns = {c["family"]: c for c in result["connections"]}
|
||||||
|
assert conns["IPv4"] == {"family": "IPv4", "addr": "10.0.0.5", "state": "up",
|
||||||
|
"rtt": 14.2, "statetime": 1000.0, "lastbeat": 2000.0}
|
||||||
|
assert conns["IPv6"]["state"] == "overdue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_connections_empty():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["connections"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_connection_rtt_uses_latest_sample():
|
||||||
|
"""rtt should reflect the most recent sample, not the oldest retained one."""
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConnFull("IPv4", "10.0.0.5", "up", None, 1000.0, 2000.0,
|
||||||
|
rtts=[100.0, 50.0, 14.2]),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
conns = {c["family"]: c for c in result["connections"]}
|
||||||
|
assert conns["IPv4"]["rtt"] == 14.2
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tests for the plugin-accordion visibility filter in http.py."""
|
||||||
|
from hbd.server.http import _visible_plugin_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_excludes_rtt_history_keys():
|
||||||
|
plugin_data = {
|
||||||
|
"cpu_monitor": [],
|
||||||
|
"rtt_ipv4": [],
|
||||||
|
"rtt_ipv6": [],
|
||||||
|
"network_monitor": [],
|
||||||
|
}
|
||||||
|
result = _visible_plugin_names(plugin_data)
|
||||||
|
assert sorted(result) == ["cpu_monitor", "network_monitor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_empty_when_no_plugins():
|
||||||
|
assert _visible_plugin_names({}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_plugin_names_keeps_names_not_prefixed_with_rtt_():
|
||||||
|
plugin_data = {"nagios_runner": [], "os_info": []}
|
||||||
|
result = _visible_plugin_names(plugin_data)
|
||||||
|
assert sorted(result) == ["nagios_runner", "os_info"]
|
||||||
@@ -86,61 +86,53 @@ def test_delete_channel_persisted_after_write(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Visibility logic (mirrors http.py _visible_channels_for_user)
|
# Visibility logic (owner-presence rule, hbd.server.config_access)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from hbd.server import config_access as ca # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _visible(config, 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 {}
|
all_channels = config.get("notification_channels") or {}
|
||||||
if user.get("admin"):
|
if user.get("admin"):
|
||||||
return set(all_channels.keys())
|
return set(all_channels.keys())
|
||||||
username = user["username"]
|
return set(ca.user_channels(all_channels, 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 = {
|
CONFIG_VISIBILITY = {
|
||||||
"notification_channels": {
|
"notification_channels": {
|
||||||
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
"alice_priv": {"type": "email", "owner": "alice",
|
||||||
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
||||||
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
|
"bob_priv": {"type": "signal", "owner": "bob", "user": "+1", "recipient": "+2"},
|
||||||
"user": "+1", "recipient": "+2"},
|
"stale_flag": {"type": "pushover", "token": "t2", "user": "u2", "private": True},
|
||||||
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_public_channel_visible_to_all():
|
def test_global_channel_visible_to_all():
|
||||||
for uname in ("alice", "bob", "carol"):
|
for uname in ("alice", "bob", "carol"):
|
||||||
user = {"username": uname, "admin": False}
|
assert "pub_ch" in _visible(CONFIG_VISIBILITY, {"username": uname, "admin": False})
|
||||||
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
|
|
||||||
|
|
||||||
|
|
||||||
def test_private_channel_visible_only_to_owner():
|
def test_owned_channel_visible_only_to_owner():
|
||||||
alice = {"username": "alice", "admin": False}
|
alice = {"username": "alice", "admin": False}
|
||||||
bob = {"username": "bob", "admin": False}
|
bob = {"username": "bob", "admin": False}
|
||||||
carol = {"username": "carol", "admin": False}
|
|
||||||
|
|
||||||
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
|
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, bob)
|
||||||
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
|
|
||||||
|
|
||||||
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
||||||
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sees_all_channels():
|
def test_admin_sees_all_channels():
|
||||||
admin = {"username": "adminuser", "admin": True}
|
admin = {"username": "adminuser", "admin": True}
|
||||||
visible = _visible(CONFIG_VISIBILITY, admin)
|
assert _visible(CONFIG_VISIBILITY, admin) == {"pub_ch", "alice_priv", "bob_priv", "stale_flag"}
|
||||||
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_owned_channel_is_public_by_default():
|
def test_stale_private_flag_without_owner_is_global():
|
||||||
|
"""Owner-presence is the single signal; a leftover private flag is ignored."""
|
||||||
alice = {"username": "alice", "admin": False}
|
alice = {"username": "alice", "admin": False}
|
||||||
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
|
assert "stale_flag" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+102
-10
@@ -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", "channels", "hosts")
|
assert s["section_mode"] in ("form", "yaml", "channels", "hosts", "thresholds")
|
||||||
|
|
||||||
|
|
||||||
def test_sections_have_api_section():
|
def test_sections_have_api_section():
|
||||||
@@ -42,15 +42,13 @@ def test_network_section_has_editable_fields():
|
|||||||
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_sections_have_correct_mode():
|
def test_thresholds_and_dns_section_modes():
|
||||||
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"}
|
by_id = {s["id"]: s for s in sections}
|
||||||
assert "channels" not in yaml_sections # now uses "channels" mode
|
assert by_id["thresholds"]["section_mode"] == "thresholds"
|
||||||
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
assert by_id["thresholds"]["api_section"] == "thresholds"
|
||||||
assert "thresholds" in yaml_sections
|
assert by_id["dns"]["section_mode"] == "form"
|
||||||
assert "dns" in yaml_sections
|
assert by_id["dns"]["api_section"] == "dns"
|
||||||
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
|
||||||
assert yaml_sections["dns"]["api_section"] == "dns"
|
|
||||||
|
|
||||||
|
|
||||||
def test_hosts_section_uses_hosts_mode():
|
def test_hosts_section_uses_hosts_mode():
|
||||||
@@ -70,7 +68,7 @@ def test_channels_section_uses_channels_mode():
|
|||||||
assert ch["name"] == "pushover_ops"
|
assert ch["name"] == "pushover_ops"
|
||||||
assert ch["type"] == "pushover"
|
assert ch["type"] == "pushover"
|
||||||
assert "owner" in ch
|
assert "owner" in ch
|
||||||
assert "private" in ch
|
assert "editable" in ch
|
||||||
|
|
||||||
|
|
||||||
def test_channel_type_schemas_exported():
|
def test_channel_type_schemas_exported():
|
||||||
@@ -112,3 +110,97 @@ def test_users_section_has_user_list():
|
|||||||
assert users_sec["users"][0]["username"] == "alice"
|
assert users_sec["users"][0]["username"] == "alice"
|
||||||
# Password hash never exposed
|
# Password hash never exposed
|
||||||
assert "password" not in users_sec["users"][0]
|
assert "password" not in users_sec["users"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-user filtering (owners/managers on the settings page)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from types import SimpleNamespace # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
MULTI_CFG = {
|
||||||
|
**CFG,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
|
||||||
|
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
|
||||||
|
},
|
||||||
|
"notification_channels": {
|
||||||
|
"global_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"bob_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "bob"},
|
||||||
|
"carol_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "carol"},
|
||||||
|
},
|
||||||
|
"threshold_configs": {
|
||||||
|
"bob_tc": {"owner": "bob", "thresholds": {}},
|
||||||
|
"carol_tc": {"owner": "carol", "thresholds": {}},
|
||||||
|
},
|
||||||
|
"hosts": {
|
||||||
|
"bobhost": {"owner": "bob"},
|
||||||
|
"managedhost": {"owner": "carol", "managers": ["bob"]},
|
||||||
|
"carolhost": {"owner": "carol"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BOB = SimpleNamespace(username="bob", admin=False)
|
||||||
|
ADMIN = SimpleNamespace(username="alice", admin=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_sees_only_three_sections():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
assert [s["id"] for s in sections] == ["channels", "hosts", "thresholds"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_sections_hide_admin_fields():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
for s in sections:
|
||||||
|
assert s["fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_hosts_filtered_with_is_owner():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
hosts = next(s for s in sections if s["id"] == "hosts")["hosts"]
|
||||||
|
by_name = {h["name"]: h for h in hosts}
|
||||||
|
assert set(by_name) == {"bobhost", "managedhost"}
|
||||||
|
assert by_name["bobhost"]["is_owner"] is True
|
||||||
|
assert by_name["managedhost"]["is_owner"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_channels_filtered_with_editable():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
|
||||||
|
assert set(chans) == {"global_ch", "bob_ch"}
|
||||||
|
assert chans["bob_ch"]["editable"] is True
|
||||||
|
assert chans["global_ch"]["editable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_everything_with_editable():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=ADMIN)
|
||||||
|
ids = [s["id"] for s in sections]
|
||||||
|
assert "network" in ids and "users" in ids
|
||||||
|
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
|
||||||
|
assert set(chans) == {"global_ch", "bob_ch", "carol_ch"}
|
||||||
|
assert all(c["editable"] for c in chans.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_data_pickers_filtered_for_nonadmin():
|
||||||
|
data = settings_mod.get_settings_data(MULTI_CFG, user=BOB)
|
||||||
|
assert data["all_channel_names"] == ["bob_ch", "global_ch"]
|
||||||
|
assert data["all_threshold_configs"] == ["bob_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_user_means_admin_view():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG) # auth disabled
|
||||||
|
assert len(sections) > 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtering_unaffected_by_other_users_in_config():
|
||||||
|
"""The users-section loop must not clobber the requesting username (regression)."""
|
||||||
|
cfg = dict(MULTI_CFG)
|
||||||
|
cfg["users"] = {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
|
||||||
|
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
|
||||||
|
"zed": {"full_name": "Zed", "admin": False, "password": "x"}, # bob is not last
|
||||||
|
}
|
||||||
|
sections = settings_mod.get_settings_sections(cfg, user=BOB)
|
||||||
|
hosts = {h["name"] for h in next(s for s in sections if s["id"] == "hosts")["hosts"]}
|
||||||
|
assert hosts == {"bobhost", "managedhost"}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tests for RTT history capture in udp.py's handle_datagram."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from hbd.common.proto import dicttos
|
||||||
|
from hbd.server import hbdclass
|
||||||
|
from hbd.server.udp import handle_datagram, parse_message
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def sendto(self, data, addr):
|
||||||
|
self.sent.append((data, addr))
|
||||||
|
|
||||||
|
|
||||||
|
def _htb(name, rtt=None, interval=0, boot=0):
|
||||||
|
d = {"name": name, "interval": interval, "id": 0}
|
||||||
|
if rtt is not None:
|
||||||
|
d["rtt"] = rtt
|
||||||
|
if boot:
|
||||||
|
d["boot"] = boot
|
||||||
|
return parse_message(dicttos("HTB", d))
|
||||||
|
|
||||||
|
|
||||||
|
def _base_ctx():
|
||||||
|
return {
|
||||||
|
"config": {},
|
||||||
|
"hbdclass": hbdclass,
|
||||||
|
"msg_to_websockets": None,
|
||||||
|
"DEBUG": 0,
|
||||||
|
"verbose": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_records_rtt_history_for_new_connection():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host", None)
|
||||||
|
handle_datagram(_htb("rtt-hist-host", rtt=42.5), ("127.0.0.1", 50000),
|
||||||
|
_FakeTransport(), _base_ctx())
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host"]
|
||||||
|
samples = host.plugin_data.get("rtt_ipv4")
|
||||||
|
assert samples is not None
|
||||||
|
assert len(samples) == 1
|
||||||
|
ts, data = samples[0]
|
||||||
|
assert data == {"rtt": 42.5}
|
||||||
|
assert isinstance(ts, float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_appends_rtt_history_across_heartbeats():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host2", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
handle_datagram(_htb("rtt-hist-host2", rtt=10.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
handle_datagram(_htb("rtt-hist-host2", rtt=20.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host2"]
|
||||||
|
samples = host.plugin_data["rtt_ipv4"]
|
||||||
|
assert [d["rtt"] for _, d in samples] == [10.0, 20.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_datagram_skips_rtt_history_when_rtt_missing():
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host3", None)
|
||||||
|
handle_datagram(_htb("rtt-hist-host3", rtt=None), ("127.0.0.1", 50000),
|
||||||
|
_FakeTransport(), _base_ctx())
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host3"]
|
||||||
|
assert "rtt_ipv4" not in host.plugin_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_update_fires_on_recovery_even_with_rtt_history():
|
||||||
|
"""Regression for Finding 1: rtt_* keys must not permanently disable the
|
||||||
|
request_update gate. A connection recovering from a non-UP state must
|
||||||
|
still be asked to resend real plugin data, even though rtt_ipv4 already
|
||||||
|
holds samples from before the drop.
|
||||||
|
"""
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host4", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
|
||||||
|
# First heartbeat: brand-new host, no plugin data at all yet.
|
||||||
|
handle_datagram(_htb("rtt-hist-host4", rtt=15.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host4"]
|
||||||
|
assert host.plugin_data.get("rtt_ipv4") # rtt history now non-empty
|
||||||
|
|
||||||
|
# Simulate a recovery: connection was dropped (e.g. OVERDUE->UP after a
|
||||||
|
# missed heartbeat) and is about to come back UP on the next heartbeat.
|
||||||
|
conn = host.connections["IPv4"]
|
||||||
|
conn.state = hbdclass.Connection.DOWN
|
||||||
|
|
||||||
|
transport.sent.clear()
|
||||||
|
handle_datagram(_htb("rtt-hist-host4", rtt=16.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
ack_data, _ = transport.sent[0]
|
||||||
|
ack = parse_message(ack_data)
|
||||||
|
assert ack.get("request_update")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_recovery_preserves_real_plugin_data_and_rtt_history():
|
||||||
|
"""An ordinary reconnect (no boot flag) — e.g. OVERDUE->UP after a
|
||||||
|
transient network blip — must NOT wipe already-collected real plugin
|
||||||
|
data (e.g. cpu_monitor, os_info) or rtt_* history. Only an actual
|
||||||
|
client reboot invalidates that data (see the boot-flag test below).
|
||||||
|
"""
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host5", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
|
||||||
|
for rtt in (10.0, 11.0, 12.0):
|
||||||
|
handle_datagram(_htb("rtt-hist-host5", rtt=rtt), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host5"]
|
||||||
|
assert len(host.plugin_data["rtt_ipv4"]) == 3
|
||||||
|
|
||||||
|
# Simulate a drop, and pretend the client had previously sent real
|
||||||
|
# plugin data (collected before the connection went down).
|
||||||
|
conn = host.connections["IPv4"]
|
||||||
|
conn.state = hbdclass.Connection.DOWN
|
||||||
|
host.add_plugin_data("os_info", {"os": "linux"}, timestamp=time.time())
|
||||||
|
assert "os_info" in host.plugin_data
|
||||||
|
|
||||||
|
# Ordinary recovery heartbeat — no boot flag.
|
||||||
|
handle_datagram(_htb("rtt-hist-host5", rtt=13.0), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
assert "os_info" in host.plugin_data
|
||||||
|
assert len(host.plugin_data["rtt_ipv4"]) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_recovery_clears_real_plugin_data_but_preserves_rtt_history():
|
||||||
|
"""A recovery heartbeat carrying the boot flag (client process actually
|
||||||
|
restarted) must still wipe stale real plugin data, while rtt_* history
|
||||||
|
(still a valid measurement, unaffected by a client reboot) survives.
|
||||||
|
"""
|
||||||
|
hbdclass.Host.hosts.pop("rtt-hist-host6", None)
|
||||||
|
transport = _FakeTransport()
|
||||||
|
ctx = _base_ctx()
|
||||||
|
|
||||||
|
for rtt in (10.0, 11.0, 12.0):
|
||||||
|
handle_datagram(_htb("rtt-hist-host6", rtt=rtt), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
host = hbdclass.Host.hosts["rtt-hist-host6"]
|
||||||
|
assert len(host.plugin_data["rtt_ipv4"]) == 3
|
||||||
|
|
||||||
|
conn = host.connections["IPv4"]
|
||||||
|
conn.state = hbdclass.Connection.DOWN
|
||||||
|
host.add_plugin_data("os_info", {"os": "linux"}, timestamp=time.time())
|
||||||
|
assert "os_info" in host.plugin_data
|
||||||
|
|
||||||
|
# Recovery heartbeat with boot=1 — client process actually restarted.
|
||||||
|
handle_datagram(_htb("rtt-hist-host6", rtt=13.0, boot=1), ("127.0.0.1", 50000), transport, ctx)
|
||||||
|
|
||||||
|
assert "os_info" not in host.plugin_data
|
||||||
|
assert len(host.plugin_data["rtt_ipv4"]) == 4
|
||||||
Reference in New Issue
Block a user