Compare commits

..
61 Commits
Author SHA1 Message Date
andreas 880dc0e33f version 5.3.12
Release / release (push) Successful in 47s
2026-07-09 20:43:59 -04:00
andreasandClaude Fable 5 f06b2ef9e8 fix: users loop clobbered requesting username in settings filtering
The users-section loop reused 'username' as its loop variable, overwriting
the requesting user's name so host/threshold filtering compared against the
last user in the config. Rename to 'uname' and add a regression test with a
user that is not last in the users dict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 18:48:05 -04:00
andreasandClaude Fable 5 447b9574a3 fix: type annotations for mypy parity with master
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:52:02 -04:00
andreasandClaude Fable 5 e98cca22f7 docs: ownership rules and settings access for owners/managers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:50:24 -04:00
andreasandClaude Fable 5 5fe069a1a2 feat: retire per-channel private flag on profile page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:53:55 -04:00
andreasandClaude Fable 5 ca2868a888 feat: settings UI renders per-user edit rights and owner controls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:52:44 -04:00
andreasandClaude Fable 5 ddb1b2b875 feat: settings page accessible to all authenticated users
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:50:01 -04:00
andreasandClaude Fable 5 39c4d45bd7 feat: per-user filtering of settings sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:48:58 -04:00
andreasandClaude Fable 5 07fefab861 feat: scoped non-admin saves through POST /api/0/config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:47 -04:00
andreasandClaude Fable 5 6b4524b30d feat: threshold form payload carries per-config owner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:10 -04:00
andreasandClaude Fable 5 a149a1e285 feat: channel ownership rule — owner-presence means private, admin promote/demote
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:45:21 -04:00
andreasandClaude Fable 5 dd939ab86e feat: scoped threshold-config merge for non-admin saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:56 -04:00
andreasandClaude Fable 5 5c6f462081 feat: scoped host merge for non-admin config saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:08 -04:00
andreasandClaude Fable 5 98b50dbbf4 feat: ownership visibility helpers for config entities
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:42:00 -04:00
andreasandClaude Opus 4.8 960822918a fix: don't re-announce boot/shutdown on SIGHUP restart
Setting args.boot = False after sending the boot message was dead code: the
SIGHUP restart re-execs via os.execv(sys.argv[0], sys.argv) with the original
argv, so the re-launched process re-parsed -b and announced a boot again. The
exiting process also sent a spurious shutdown (send_shutdown armed by -b), so
each config reload looked like a host reboot to the server.

Strip -b/--boot from the argv passed to execv, and skip the shutdown message
in cleanup() when dorestart is set. A real SIGTERM/SIGINT shutdown still sends
shutdown as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:46:09 -04:00
andreas a376ebba8d version 5.3.11
Release / release (push) Successful in 48s
2026-06-26 11:39:23 -04:00
andreasandClaude Opus 4.8 f603ef9bd2 fix: declare plugin interval so stale data waits two full intervals
The server inferred each plugin's collection interval from the gap between
the last two received PLG samples, then expired data at interval * 3. After
an outage this guess was wrong: the request_update re-send of collect-once
InfoPlugins produced two close samples, yielding a tiny inferred interval
that purged permanent info data minutes after recovery.

Clients now declare each plugin's interval in the PLG message (_interval).
The server uses it directly (from the first post-recovery sample), expiring
at interval * 3 so live data survives at least two full intervals; interval
0 marks collect-once InfoPlugins that never go stale. Legacy clients omit
the field and fall back to the previous inferred-gap behavior.

Adds _interval to all three clients: hbc, hbc_mini.py, hbc_mini.c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:48 -04:00
andreasandClaude Opus 4.8 731545dd0e fix: cap event buffer and replay only recent messages on dashboard connect
data.msgs grew without bound (eventlog appended forever, also persisted in
the pickle), and ws.py replayed the entire history to every dashboard
client on connect - one JSON frame per message - making page reloads
progressively sluggish. Cap the buffer in eventlog() (configurable via
msg_buffer_size, default 500) and slice the WebSocket replay to the last
30, matching the window the HTTP render already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:40 -04:00
andreasandClaude Opus 4.8 356c77b0b8 fix: strip plugin timers when pickling Host to prevent save failure
Host.plugin_timers holds asyncio TimerHandle objects (and their lambda
callbacks) which are not picklable, causing the 5-minute state save to
fail with "Can't pickle local object reset_plugin_timer.<locals>.<lambda>".
Add Host.__getstate__ to reset plugin_timers to {} before pickling,
mirroring Connection.__getstate__; timers are recreated on the next PLG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:35 -04:00
andreasandClaude Sonnet 4.6 6282077fe0 fix: correct zero-safe pathconf checks and connectivity prefix match
- Use `is not None` for pathconf values so 0 is not silently dropped
- Broaden connectivity prefix check to catch bare "connectivity" key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:07:54 -04:00
Andreas WredeandClaude Sonnet 4.6 ddd857173b fix: address security vulnerabilities from audit
- Path traversal: confine avatar file serving to avatar_dir (defaults to
  config file directory); validate on both read and write
- UDP owner injection: server-configured owner now takes precedence over
  UDP-supplied value, matching the documented intent
- Open redirect: reject non-relative next= values after login
- Stored XSS: enable Jinja2 autoescape on all template environments;
  add escHtml() helper in live.html and apply to all innerHTML sinks
  sourced from network data (host names, addrs, states, log messages)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:06:05 -04:00
Andreas WredeandClaude Sonnet 4.6 f46f725d12 feat: add Windows hbc client with PyInstaller spec and NSSM install script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:53:57 -04:00
Andreas WredeandClaude Sonnet 4.6 3da6976b53 fix: don't purge connectivity/rtt alerts in purge_stale_alerts
These entries are set by the connection state machine, not by threshold
config, so they have no threshold entry and were being deleted on every
startup. Guard them explicitly so overdue/down alerts survive the purge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:45:47 -04:00
Andreas WredeandClaude Sonnet 4.6 3a0c48e32b fix: restore connectivity alerts for overdue/unknown/down hosts on startup
restore_connection_timers now calls _set_connectivity_alert("CRITICAL")
for DOWN, OVERDUE, and UNKNOWN connections, ensuring alerts are present
even if hbd was shut down before the transition callbacks recorded them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:40:04 -04:00
Andreas WredeandClaude Sonnet 4.6 cf6e19704f fix: clear plugin data and timers on connection UP transition
Moves the plugin-state purge from the boot flag to the UP transition,
so stale history and alerts are cleared on any reconnect (reboot, or
recovery from overdue/unknown) not just detected reboots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:35:58 -04:00
Andreas WredeandClaude Sonnet 4.6 b0addd7c67 feat: clear alerts for individual plugin metrics that disappear between samples
When a PLG message arrives with fewer keys than the previous sample,
alert states for the missing metrics are removed immediately. Handles
nagios checks removed from configuration while the runner plugin continues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:32:38 -04:00
Andreas WredeandClaude Sonnet 4.6 32680d34a4 feat: show alerts for all hosts on Alerts page, not just watched
Notifications are still gated by host.watched; only the listing changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:24:33 -04:00
Andreas WredeandClaude Sonnet 4.6 a7abdcb5c5 fix: restore host link from Dashboard to Host Overview
live.html used host.raw_name which stateinfo() never included — the
hash was always empty. Use host.name (the raw hostname stateinfo()
does include). Also exclude plugin_timers from stateinfo() to prevent
asyncio handles from breaking jsons().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:15:27 -04:00
Andreas WredeandClaude Sonnet 4.6 7bab15ae52 fix: don't set stale timer until two plugin samples establish real interval
Avoids false-stale firing for slow plugins (e.g. nagios_runner at 300 s)
when the heartbeat interval is much shorter. On the first sample cancel
any leftover timer; arm the 3× stale timer only after the second sample.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:00:09 -04:00
Andreas Wrede e0443293e9 Merge branch 'master' of git.wrede.ca:andreas/heartbeat
Release / release (push) Successful in 44s
2026-06-06 08:31:26 -04:00
Andreas Wrede 39670f4e63 version 5.3.10 2026-06-06 08:28:43 -04:00
Andreas WredeandClaude Sonnet 4.6 2e88ee2269 feat: clear stale plugin data and persist OAuth users to config
- hbdclass: add per-plugin stale timers; clear history and alerts after
  3× heartbeat interval with no PLG data received
- udp: wire stale timer on every PLG message via _make_plugin_stale_callback
- http: persist new OAuth users to config file on first login

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:27:20 -04:00
andreas 2ef7d473c3 Merge pull request 'hbc_mini.c: make it compile on NetBSD' (#1) from woods/heartbeat:master into master
Merge pull request: hbc_mini.c: make it compile on NetBSD
2026-06-03 12:05:29 -04:00
woods 862a9cdea0 hbc_mini.c: make it work on NetBSD
This fixes the numbers by using the correct MIB to match the struct.
2026-06-02 13:42:11 -07:00
woods 9351938b15 hbc_mini.c: make it compile on NetBSD
Use the public "struct uvmexp_sysctl" instead of "struct uvmexp".

The numbers from the memory_monitor are wonky, but it builds and runs.
2026-06-02 12:05:42 -07:00
andreas b6ef2fe065 Merge branch 'master' of git.wrede.ca:andreas/heartbeat
sequencing
2026-06-02 08:01:47 -04:00
andreas d5d2f066b3 fix: don't use pusbover title 2026-06-02 08:01:32 -04:00
Andreas Wrede d9563392c3 fix: remove bak file in bumpminor.sh 2026-06-01 08:34:07 -04:00
andreasandClaude Sonnet 4.6 5f090b9d96 feat: auto-scale CPU history graph Y axis
Y axis now fits the actual data range with 10% padding rather than
fixed 0-100%. Grid lines use nice tick steps (1/2/5/10 × magnitude).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:59:54 -04:00
andreas 3cc1d92eb4 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-06-01 07:56:02 -04:00
andreasandClaude Sonnet 4.6 2ddba203df feat: add CPU usage history graph to CPU Monitor section
Renders an SVG line chart above the CPU Usage row using all available
history samples (up to 100). Color adapts green/orange/red by load level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:55:55 -04:00
Andreas Wrede 8a1f412d1d version 5.3.9
Release / release (push) Successful in 43s
2026-05-31 20:58:58 -04:00
Andreas WredeandClaude Sonnet 4.6 40c44f53f1 feat: auto-update CHANGELOG and README in bumpminor.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:58:46 -04:00
andreas a6fe8546a8 Update README.md 2026-05-31 20:38:03 -04:00
Andreas Wrede e56660454d tidy up what commited 2026-05-30 15:17:36 -04:00
Andreas WredeandClaude Sonnet 4.6 9cbf0ecb13 docs: update CHANGELOG for 5.3.7 and 5.3.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:15:25 -04:00
Andreas Wrede 313bbd37ac version 5.3.8
Release / release (push) Successful in 42s
2026-05-30 15:06:46 -04:00
Andreas WredeandClaude Sonnet 4.6 f7320644f3 fix: avoid SIGPIPE in changelog step by using grep -m 1
Replacing head -1 (and the broken head -2|tail -1 attempt) with grep -m 1
stops grep after the first match, eliminating the SIGPIPE that caused exit 141.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:06:19 -04:00
Andreas Wrede 76e11b92f2 version 5.3.7
Release / release (push) Failing after 47s
2026-05-30 14:48:43 -04:00
Andreas WredeandClaude Sonnet 4.6 d39c0da5fe fix: use GITHUB_REF/GITHUB_OUTPUT in release workflow
Gitea Actions uses GitHub-compatible variable names, not GITEA_* variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:47:42 -04:00
Andreas WredeandClaude Sonnet 4.6 832b9d04d8 docs: use absolute URLs in wiki home page for Gitea wiki compatibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:59:08 -04:00
Andreas WredeandClaude Sonnet 4.6 44d5f15a67 docs: add wiki home page with overview and getting started guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:45:33 -04:00
Andreas WredeandClaude Sonnet 4.6 37b8e35a26 docs: add DARK_MODE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:34:59 -04:00
Andreas WredeandClaude Sonnet 4.6 fa317a3b78 feat: add dark mode with light/dark/auto theme setting
Theme preference stored in localStorage (auto follows the OS setting).
The chosen data-theme attribute is applied synchronously in <head> to
avoid any flash of unstyled content. CSS custom properties handle all
surface, text, border and input colours across every page. The
Appearance section on the profile page lets each user switch modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:33:37 -04:00
Andreas WredeandClaude Sonnet 4.6 8729fe7038 feat: sort hosts, thresholds, and channels alphabetically on settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:01:47 -04:00
Andreas WredeandClaude Sonnet 4.6 f4231dd5f3 fix: preserve log message order when replaying history on connect
Send history messages newest-first from the server, tagged with
history=True so the client appends rather than prepends them, avoiding
reverse-chronological display on initial load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:18:05 -04:00
andreasandClaude Sonnet 4.6 c47576637f feat: suppress alerts for unwatched hosts
Hosts with watch: false in config no longer appear in the Alerts page
or nav bar alert counts. Events still appear in the Log of Events.
Hosts without a config entry default to watch: false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:54:53 -04:00
Andreas Wrede 2b9523ec28 finetune tabe and font sizes 2026-05-14 06:29:00 -04:00
Andreas WredeandClaude Sonnet 4.6 610ad0af30 feat: add UNKNOWN level filter to Log of Events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:01:57 -04:00
Andreas WredeandClaude Sonnet 4.6 69b5b410ed feat: replace Dynamic DNS YAML editor with a web form
Adds structured form fields for nsupdate_bin, rndc_key, and dyndomains
(comma-separated list). Wires list-type editable fields through the
generic stageFormSection path and adds DNS support to
apply_structured_section in configio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:12:44 -04:00
Andreas WredeandClaude Sonnet 4.6 8b2b0fd9d0 feat: add per-metric grace period input to thresholds settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:56:21 -04:00
41 changed files with 3685 additions and 251 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"permissions": {
"allow": [
"Edit(*)",
"Bash(pytest *)",
"Bash(python *)",
"Bash(python3 *)",
"Bash(.venv/bin/pytest *)",
"Bash(npm *)",
"Bash(git *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(find *)",
"Bash(mkdir *)",
"Bash(touch *)",
"Bash(uv *)"
]
}
}
+24 -6
View File
@@ -10,6 +10,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python - name: Set up Python
run: | run: |
@@ -18,22 +20,38 @@ jobs:
- name: Install build tools - name: Install build tools
run: | run: |
python3 -m pip install --upgrade pip python3 -m venv .venv
python3 -m pip install build twine .venv/bin/pip install --upgrade pip
.venv/bin/pip install build twine
- name: Build package - name: Build package
run: python3 -m build run: .venv/bin/python -m build
- name: Extract version from tag - name: Extract version from tag
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep -m 1 -v "^${GITHUB_REF#refs/tags/}$")
if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD")
else
CHANGELOG="Initial release"
fi
# Write multiline to output
{
echo "CHANGELOG<<EOF"
echo "$CHANGELOG"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Upload to Gitea PyPI registry - name: Upload to Gitea PyPI registry
env: env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: | run: |
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/* .venv/bin/python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
- name: Create release - name: Create release
uses: actions/gitea-release-action@v1 uses: actions/gitea-release-action@v1
@@ -42,4 +60,4 @@ jobs:
dist/*.whl dist/*.whl
dist/*.tar.gz dist/*.tar.gz
title: "Release ${{ steps.get_version.outputs.VERSION }}" title: "Release ${{ steps.get_version.outputs.VERSION }}"
body: "Release version ${{ steps.get_version.outputs.VERSION }}" body: "${{ steps.changelog.outputs.CHANGELOG }}"
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
*.pyo *.pyo
.flake8 .flake8
.venv/ .venv/
.continue/
test/ test/
build/ build/
dist/ dist/
+499
View File
@@ -0,0 +1,499 @@
# Changelog
All notable changes to this project are documented here, organized by release.
## [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]
### Added
- clear stale plugin data and persist OAuth users to config
- auto-scale CPU history graph Y axis
- add CPU usage history graph to CPU Monitor section
### Fixed
- remove bak file in bumpminor.sh
---
## [5.3.9]
### Added
- auto-update CHANGELOG and README in bumpminor.sh
---
## [5.3.8]
### Added
- Wiki home page with overview and getting started guide
### Fixed
- Release workflow: use `GITHUB_REF`/`GITHUB_OUTPUT` (Gitea Actions uses GitHub-compatible variable names)
- Release workflow: replace `head -1` with `grep -m 1` to avoid SIGPIPE (exit 141) in changelog step
---
## [5.3.7]
### Added
- Dark mode with light/dark/auto theme setting
- UNKNOWN level filter in Log of Events
- Per-metric grace period input in threshold settings
- Replace Dynamic DNS YAML editor with a web form
- Sort hosts, thresholds, and channels alphabetically on settings page
- Suppress alerts for unwatched hosts
### Fixed
- Preserve log message order when replaying history on connect
---
## [5.3.6]
### Added
- MIT license
### Fixed
- Correct ZFS pool status threshold operator and add per-metric grace
- Normalize email and domain fields
- Move dependencies back under `[project]` in pyproject.toml
---
## [5.3.4]
### Fixed
- Run full reload after HTTP config publish, not just `config.reload()`
---
## [5.3.3]
### Added
- Replace YAML threshold editor with a form-based UI
- Replace multi-select fields with dual-panel picker on settings page
- Nav bar button to publish pending config changes
- Host, level, and message filters in Log of Events
### Fixed
- Remove container max-width; stop stretching inputs on settings page
### Removed
- Legacy `dyndnshosts`/`drophosts` config keys
---
## [5.3.2]
### Added
- Retry DNS resolution indefinitely; add `-4`/`-6` address-family flags to `hbc` and `hbc_mini`
- Replace YAML hosts editor with form-based CRUD table
- Replace YAML notification channel editor with form-based UI
### Fixed
- Support list-valued `threshold_config` in hosts table
- Derive hosts threshold config list from config file keys
- Replace channel checkboxes in Users table with multi-select
- Support plugin-level `enabled: false` in threshold config
- Always populate glance strip for all hosts on page load
- Fetch host info on initial page load
---
## [5.3.1]
### Added
- Host info section in Host Overview (fetched and rendered on card expand)
- `GET /api/0/hosts/{hostname}/info` endpoint
- Show suffix-matched metric coverage in host info threshold table
- Move `hbc_version` and `hbc_type` out of `os_info` into the host info section
### Fixed
- Correct `THRESHOLD_DEFAULTS` metric keys and add missing defaults
---
## [5.3.0]
### Added
- Profile page self-service: change identity, password, and notification channels
- Settings page editor with form sections, YAML editors, stage/publish/rollback workflow
- Config read API: `GET /api/0/config`, `/section/{name}`, `/backups`
- Config write API: `POST /api/0/config`, `POST /api/0/config/rollback`
- `configio` module for comment-preserving YAML round-trip writes
- Multi-provider OAuth2 login page and generic provider routes
- Log login/logout events to the event log with auth source
### Fixed
- ZFS monitor alerts dropped on restart with wildcard pool thresholds
- Preserve OAuth users across config reload
- Config API error handling, consistent 403 messages, deduplicated key lists
- Validate password body type; coerce `notification_channels` to strings in profile API
- Preserve OAuth `client_secret` on roundtrip; harden rollback path validation
---
## [5.2.6]
### Added
- Alerts host-filter field with URL query parameter and notify URL
- Optional logo on Gitea OAuth login button
### Fixed
- Show human-readable duration in re-notification messages
---
## [5.2.5]
### Added
- Alert CRITICAL on degraded or suspended ZFS pools (ONLINE=OK, DEGRADED=WARNING, all else=CRITICAL)
- Sign in with Gitea button on login page with OAuth2 redirect/callback routes
- OAuth2 CSRF state management
- Host owner shown in glance strip for admin users
- C port of `hbc_mini` (single-file client in `scripts/c/`)
### Fixed
- Use `base_url` config for OAuth redirect URI to handle reverse proxy deployments
- Preserve OAuth users across config reload
- Escape HTML in login page error display
---
## [5.2.4]
### Added
- `hbc`/`hbc_mini`: `owner` config field included in `os_info`; server applies to host record
- Server requests InfoPlugin refresh when a host has no plugin data
- Event log stores structured dicts; filter by user
### Fixed
- Strip `_status_code` suffix from displayed metric names in threshold alerts
- Use plain URL in Mattermost plugin metrics link
- Fall back to `default_owner` when `os_info` has no owner
---
## [5.2.3]
### Added
- `hbc`/`hbc_mini`: log name and version at startup
- Show metric name inline with hostname in alerts and notifications
### Fixed
- Send shutdown message only if a boot message was previously sent; suppress both on restart
---
## [5.2.2]
### Fixed
- Retry connection on network error instead of permanently dropping it
- Silence `aiohttp.access` log; strip plugin prefix in alerts UI
---
## [5.2.1]
### Fixed
- Threshold and logging improvements
---
## [5.2.0]
### Added
- `nagios` operator for direct exit-code severity mapping
### Fixed
- Always show `THRESHOLD_DEFAULTS` in Settings threshold config
---
## [5.1.21]
### Added
- `nagios_runner` improvements and alerts page fixes
---
## [5.1.20]
### Added
- Generic threshold matching for `nagios_runner` with `{check_name}` display support
### Fixed
- Reduce default hysteresis from 10% to 2%
- Show recovery threshold in alerts UI
---
## [5.1.19]
### Added
- Exclude ZFS ARC from `memory_percent`
- Add `uptime_seconds` to `cpu_monitor`
### Fixed
- Send boot/shutdown message on the first open connection, not blindly on the first in list
---
## [5.1.18]
### Added
- Fetch-based Update/Delete buttons with toast notifications on Host Overview
### Fixed
- Settings thresholds show correct per-config metrics; miscellaneous `hbc` fixes
---
## [5.1.17]
### Added
- Owner Update/Delete buttons on Host Overview; purge stale alerts on reload
- Retry `AsyncConnection.open()` indefinitely; drop IPv6 only on early startup failure
- Alert pie chart in the nav bar
### Fixed
- Make Alerts page scrollable
---
## [5.1.16]
### Added
- Generic `ping_monitor` thresholds; round RTT to nearest ms
---
## [5.1.15]
### Added
- Link hostnames in Live Dashboard to Host Overview
- Threshold Configurations section on settings page
### Fixed
- Suppress notifications on alert de-escalation (e.g. CRITICAL→WARNING)
- Suppress recover messages for down durations under 4 seconds
---
## [5.1.14]
### Added
- ZFS pool renderer in Host Overview
---
## [5.1.13]
### Added
- ZFS monitor plugin
- Host-level watch flag to suppress notifications
- Filter Live Dashboard and Host Overview by owner/manager
- Composable `threshold_config` list for per-host threshold layering
- Restart on SIGHUP in `hbc` and `hbc_mini`
### Fixed
- Mask `api_password` and `access_token` in settings page
---
## [5.1.12]
Internal release — no user-visible changes.
---
## [5.1.11]
### Fixed
- Install under Docker
- Clean up install script
---
## [5.1.10]
### Fixed
- Synchronize version in `hbc_mini`
- Install script no longer overwrites itself
---
## [5.1.9]
### Added
- Install `hbc_mini` via package or install script
---
## [5.1.8]
### Added
- Track `hbc` type and version
### Fixed
- Nav bar position
---
## [5.1.7]
### Added
- `hbc_mini`: single-file heartbeat client
### Fixed
- Drop dead connections on protocol error
---
## [5.1.6]
### Fixed
- Simplify event log usage; fix argument handling
---
## [5.1.5]
### Added
- Update `hbc` via `hb_install.sh` instead of code patching
---
## [5.1.4]
### Added
- Redesign Plugin Metrics page as Host Overview
---
## [5.1.3]
### Added
- Validate absolute command paths at `nagios_runner` init
- Async subprocess in `nagios_runner` with stderr capture and signal handling
- `skip_reason` field on `Plugin`; surface in `PluginLoader` init messaging
### Fixed
- Use `shlex.split()` for `nagios_runner` path validation to handle quoted paths
- Reconfigure logging to syslog after `daemonize()`
---
## [5.1.2]
### Fixed
- Plugin config lookup shadowed by `CLIENT_DEFAULTS` plugins key
- Apply grace period to all threshold alerts before logging/notifying
- RECOVER routing: use consistent level name and route via alerted channel
- Early reminder notifications and lost recovery notifications
- Non-alerting of overdue hosts
### Added
- Swiss clock widget in the UI
---
## [5.1.1]
### Added
- SMS and Matrix notification channels
- CLI commands `stop`, `restart`, and `reload` for `hbd`
- WebSocket endpoint at `http://.../ws`
- Mobile HTML pages
### Fixed
- Profile not updating
- Sortable columns in tables
---
## [5.1.0]
### Added
- Ping monitor plugin
- Persist state to pickle file; restart timers on server restart
- SIGHUP config reload for `hbd`
- Renotify on CRITICAL only; persistent user sessions
- RTT count threshold
### Fixed
- Bogus notification on new clients
- Show "overdue" in alerts instead of null
---
## [5.0.12]
### Added
- User management and settings page
---
## [5.0.10]
### Added
- Publish package to Gitea PyPI registry
---
## [5.0.9]
### Added
- Use `SO_TIMESTAMP` for RTT measurement (Linux, FreeBSD, macOS)
- Persist state to pickle file; restart timers on restart
---
## [5.0.6]
### Added
- Major codebase refactoring: restructured into client/server components
- Per-client threshold configuration
- Display and acknowledge alerts in the UI
- Proper `hbc` termination; `hbd` config reloadable at runtime
+210
View File
@@ -0,0 +1,210 @@
# Heartbeat
Heartbeat is a lightweight host monitoring system built around a simple idea: each machine you want to monitor runs a small client (`hbc`) that sends a UDP "heartbeat" packet to a central server (`hbd`) on a regular interval. If a heartbeat stops arriving, you get notified. Alongside reachability, clients can ship system metrics — CPU, memory, disk, network — and the server will alert you when any of those cross a threshold.
## How it works
```
[ monitored host ] [ your server ]
┌─────────────┐ UDP 50003 ┌────────────────────────┐
│ hbc │ ────────────> │ hbd │
│ │ │ host state tracking │
│ plugins: │ <──────────── │ threshold alerting │
│ cpu, mem, │ ACK / CMD │ notifications │
│ disk, ... │ │ web dashboard + API │
└─────────────┘ └────────────────────────┘
```
- **hbd** — the server daemon. Tracks which hosts are alive, evaluates metric thresholds, fires notifications, serves the web dashboard and REST API.
- **hbc** — the client. Sends heartbeats and plugin data over UDP. Runs on any Linux/BSD/macOS host.
- **hbc_mini** — a zero-dependency single-file alternative (`hbc_mini.py` or `hbc_mini.c`) for hosts where you can't install Python packages.
Notifications can go to Pushover, email, Mattermost, Matrix, Signal, or VoIP.ms SMS. The dashboard shows host connectivity, RTT graphs, active alerts, and per-host plugin metrics in real time via WebSocket.
---
## Getting started
This tutorial sets up a server on one machine and a client on a second machine. You'll end up with a working dashboard and your first host being monitored.
### 1. Install the server
On the machine that will run `hbd`:
```bash
git clone https://git.wrede.ca/andreas/heartbeat.git
cd heartbeat
python3 -m venv .venv
source .venv/bin/activate
pip install .
```
Verify the install:
```bash
hbd --help
```
### 2. Create a server config
Create `~/.hb.yaml`:
```yaml
hb_port: 50003 # UDP port — clients send heartbeats here
hbd_port: 50004 # HTTP port — web dashboard and API
ws_port: 50005 # WebSocket port — live dashboard updates
interval: 20 # Expected heartbeat interval (seconds)
grace: 2 # Seconds of slack before a host is considered overdue
pickfile: ~/.hb.pick
pidfile: ~/.hb.pid
logfile: ~/.hb.log
```
That's enough to get started. No hosts, no users, no notifications needed yet — the server will accept any client that connects.
### 3. Start the server
```bash
hbd serve -c ~/.hb.yaml -f -v
```
`-f` keeps it in the foreground so you can watch the log. You should see:
```
Heartbeat daemon starting on UDP :50003, HTTP :50004, WS :50005
```
Open `http://your-server:50004/live` in a browser. The dashboard is empty for now.
### 4. Install the client on a host to monitor
On the machine you want to monitor (must be able to reach the server on UDP 50003):
```bash
pip install hbd # or: copy scripts/hbc_mini.py if you can't install packages
```
#### Quick start — no config file
```bash
hbc your-server.example.com
```
Within a few seconds the server log will show the host checking in, and it will appear on the dashboard.
#### With a config file
Create `~/.hbc.yaml` on the client host:
```yaml
hb_port: 50003
interval: 10 # Send a heartbeat every 10 seconds
plugins:
cpu_monitor:
interval: 60
memory_monitor:
interval: 60
disk_monitor:
interval: 60
```
Then start the client:
```bash
hbc -c ~/.hbc.yaml your-server.example.com
```
Send a boot message at startup so the server logs when the host came up:
```bash
hbc -b -c ~/.hbc.yaml your-server.example.com
```
Run as a daemon (logs go to syslog):
```bash
hbc -d -b -c ~/.hbc.yaml your-server.example.com
```
### 5. View the dashboard
Open `http://your-server:50004/live`. You'll see the monitored host, its last heartbeat time, and RTT. Click the host name to see plugin metrics.
Navigate to `/plugins/<hostname>` for CPU, memory, and disk graphs.
### 6. Add a notification channel (optional)
Edit `~/.hb.yaml` on the server:
```yaml
notification_channels:
pushover_ops:
type: pushover
token: YOUR_APP_TOKEN
user: YOUR_USER_KEY
users:
alice:
password: pbkdf2:sha256:... # generate: hbd passwd alice
admin: true
notification_channels: [pushover_ops]
default_owner: alice
```
Generate the password hash:
```bash
hbd passwd alice
```
Paste the output into the config, then reload:
```bash
hbd reload
```
Test the channel:
```bash
hbd notify
```
### 7. Set a threshold alert (optional)
Add to `~/.hb.yaml`:
```yaml
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
disk_monitor:
partitions:
/:
percent:
warning: 80.0
critical: 90.0
```
Reload: `hbd reload`. The server will now alert when a monitored host crosses these values.
---
## What's next
| Topic | Where to look |
|---|---|
| Full server config reference | [README — Server](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#server-hbd) |
| Client options and all plugins | [README — Client](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#client-hbc) |
| Threshold alerting details | [THRESHOLD_ALERTING.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/THRESHOLD_ALERTING.md) |
| Notification channels | [NOTIFICATIONS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NOTIFICATIONS.md) |
| User accounts and roles | [USERS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/USERS.md) |
| Writing a custom plugin | [PLUGIN_DEVELOPMENT.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/PLUGIN_DEVELOPMENT.md) |
| Nagios check integration | [NAGIOS_INTEGRATION.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NAGIOS_INTEGRATION.md) |
| REST API | [HTTP_API.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/HTTP_API.md) |
| Zero-dependency client | [README — hbc_mini](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#hbc_mini--zero-dependency-client) |
+1 -1
View File
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
└────────────────────┘ └────────────────────────────┘ └────────────────────┘ └────────────────────────────┘
``` ```
**Package:** `hbd` v5.3.4 **Package:** `hbd` v5.3.12
**Python:** 3.11+ **Python:** 3.11+
### Subpackages ### Subpackages
+66
View File
@@ -0,0 +1,66 @@
# Dark Mode
Every page in the Heartbeat web UI supports light mode, dark mode, and automatic (follows the OS/browser setting). Each user picks their preference independently; it is stored in the browser and takes effect immediately without a page reload.
---
## Choosing a theme
Open your profile page (`/profile`) and scroll to the **Appearance** section. Click one of the three buttons:
| Button | Behaviour |
|--------|-----------|
| **Auto** | Follows the OS or browser dark-mode preference. Updates live if the system setting changes. |
| **Light** | Always light, regardless of system setting. |
| **Dark** | Always dark, regardless of system setting. |
The preference is stored in `localStorage` under the key `hbd_theme` and applies to the current browser only. Clearing browser storage resets it to **Auto**.
---
## Implementation notes
### No flash of unstyled content
A small synchronous `<script>` runs at the very top of `<head>`, before any CSS is parsed, and sets `data-theme="dark"` on `<html>` when the stored preference (or the system setting in auto mode) calls for dark. Because it runs before paint, there is no visible flicker on page load.
### CSS custom properties
All colours are expressed as CSS custom properties defined in `head.html`:
```
:root — light-mode values (default)
html[data-theme="dark"] — dark-mode overrides
```
Key variables:
| Variable | Purpose |
|----------|---------|
| `--bg` | Page background |
| `--surface` | Card / panel background |
| `--surface-2` / `--surface-3` | Slightly lighter/darker surfaces (table rows, hover states) |
| `--text` / `--text-sec` / `--text-muted` | Primary, secondary, muted text |
| `--border` / `--border-2``4` | Border shades from prominent to faint |
| `--link` | Hyperlink and interactive-element colour |
| `--nav-bg` | Navigation bar background |
| `--input-bg` / `--input-border` | Form control colours |
| `--shadow` / `--shadow-sm` | Box-shadow alphas |
A single global rule in `head.html` themes all `<input>`, `<select>`, and `<textarea>` elements across every page at once:
```css
html[data-theme="dark"] input:not([type=checkbox]):not([type=radio]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea { … }
```
Each page template adds its own `html[data-theme="dark"]` block for page-specific elements (cards, tables, badges, etc.).
### Auto-mode live updates
A `matchMedia` change listener in `head.html` updates `data-theme` whenever the OS preference changes, so users in **Auto** mode see the theme switch without reloading.
### Semantic colours are unchanged
Alert colours (red for critical, orange for warning, green for ok) and status indicators are intentionally left as fixed values — they are semantic signals, not surface colours, and look correct on both light and dark backgrounds.
+15 -13
View File
@@ -32,15 +32,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 +91,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 +100,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
@@ -299,7 +301,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 -1
View File
@@ -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
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.3.6" __version__ = "5.3.12"
+11 -7
View File
@@ -356,7 +356,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 +378,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 +431,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 +486,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:
@@ -563,7 +565,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 +793,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)
+3 -3
View File
@@ -127,15 +127,15 @@ 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
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
+168
View File
@@ -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
+6
View File
@@ -88,6 +88,12 @@ def apply_structured_section(data, section: str, values: dict) -> None:
for key in _SERVER_KEYS: for key in _SERVER_KEYS:
if key in values: if key in values:
data[key] = values[key] data[key] = values[key]
elif section == "dns":
for key in _DNS_KEYS:
if key in values:
data[key] = values[key]
else:
data.pop(key, None)
elif section == "users": elif section == "users":
data["users"] = values data["users"] = values
elif section == "hosts": elif section == "hosts":
+42 -2
View File
@@ -286,7 +286,7 @@ class Host:
Host.hosts[name] = self Host.hosts[name] = self
self.num = num self.num = num
self.dyn = False self.dyn = False
self.watched = True self.watched = False
self.upcount = 0 self.upcount = 0
self.interval = 0 self.interval = 0
self.doesack = -1 self.doesack = -1
@@ -297,11 +297,21 @@ class Host:
self.plugin_retention = 100 # Keep last N samples per plugin self.plugin_retention = 100 # Keep last N samples per plugin
# Alert state tracking: {metric_path: AlertState} # Alert state tracking: {metric_path: AlertState}
self.alert_states = {} self.alert_states = {}
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
self.plugin_timers = {}
# User access control # User access control
self.owner: str | None = None # username of owner self.owner: str | None = None # username of owner
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
@@ -365,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 = []
@@ -483,6 +493,8 @@ class Host:
self.managers = [] self.managers = []
if not hasattr(self, "monitors"): if not hasattr(self, "monitors"):
self.monitors = [] self.monitors = []
if not hasattr(self, "plugin_timers"):
self.plugin_timers = {}
pass pass
@@ -542,6 +554,34 @@ class Host:
""" """
return self.plugin_data return self.plugin_data
def reset_plugin_timer(self, plugin_name, timeout_seconds, callback):
"""Reset the stale-data timer for a plugin.
If no new PLG data arrives within timeout_seconds, callback(host, plugin_name)
is called so the caller can clear history and alerts.
"""
import asyncio
existing = self.plugin_timers.get(plugin_name)
if existing and not existing.cancelled():
existing.cancel()
async def _fire():
await callback(self, plugin_name)
try:
loop = asyncio.get_event_loop()
self.plugin_timers[plugin_name] = loop.call_later(
timeout_seconds, lambda: asyncio.create_task(_fire())
)
except RuntimeError:
pass
def cancel_plugin_timer(self, plugin_name):
"""Cancel the stale timer for a plugin, if any."""
handle = self.plugin_timers.pop(plugin_name, None)
if handle and not handle.cancelled():
handle.cancel()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# User-role helpers # User-role helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+120 -45
View File
@@ -20,6 +20,7 @@ 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 config_access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,19 +28,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
@@ -325,6 +332,8 @@ async def start(
from .threshold import AlertLevel from .threshold import AlertLevel
critical = warning = ok = 0 critical = warning = ok = 0
for host in hbdclass.Host.hosts.values(): for host in hbdclass.Host.hosts.values():
if not host.watched:
continue
if not _can_operate_host(user, host): if not _can_operate_host(user, host):
continue continue
levels = {s.level for s in host.alert_states.values()} levels = {s.level for s in host.alert_states.values()}
@@ -422,7 +431,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
@@ -688,7 +697,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 = []
@@ -719,7 +728,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(
@@ -776,6 +785,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,
@@ -887,6 +898,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
@@ -990,7 +1008,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
@@ -1040,7 +1058,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)
@@ -1074,7 +1092,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)
@@ -1108,19 +1126,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"],
@@ -1178,6 +1195,23 @@ async def start(
profile["full_name"], profile["full_name"],
profile["avatar_url"], profile["avatar_url"],
) )
# Persist new OAuth users to the config file so they survive restarts.
# Only write when the user isn't already in the config's users section.
if _config_path and not (config.get("users") or {}).get(user.username):
try:
disk_data = configio_mod.read_roundtrip(_config_path)
if not disk_data.get("users"):
disk_data["users"] = {}
disk_data["users"][user.username] = {
k: v for k, v in [
("full_name", user.full_name),
("avatar", user.avatar),
] if v
}
configio_mod.write_config(_config_path, disk_data)
logger.info("Persisted OAuth user %r to config", user.username)
except Exception as exc:
logger.warning("Failed to persist OAuth user %r to config: %s", user.username, exc)
session_token = users_mod.create_session(user.username) session_token = users_mod.create_session(user.username)
eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}") eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}")
resp = web.HTTPFound("/") resp = web.HTTPFound("/")
@@ -1253,12 +1287,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:
@@ -1269,6 +1307,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)
@@ -1304,25 +1349,49 @@ async def start(
attrs.pop("client_secret", None) attrs.pop("client_secret", None)
data["oauth"] = new_oauth data["oauth"] = new_oauth
for section in ("notification_channels", "dns"): if "notification_channels" in payload:
if section in payload: configio_mod.apply_yaml_section(data, "notification_channels", payload["notification_channels"])
configio_mod.apply_yaml_section(data, section, payload[section])
if "dns" in payload:
dns_payload = payload["dns"]
if isinstance(dns_payload, str):
configio_mod.apply_yaml_section(data, "dns", dns_payload)
else:
configio_mod.apply_structured_section(data, "dns", dns_payload)
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)
@@ -1373,19 +1442,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."""
@@ -1409,7 +1472,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,
} }
@@ -1474,9 +1537,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)
@@ -1541,12 +1607,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)
@@ -1634,7 +1700,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"] = [
+8 -1
View File
@@ -114,6 +114,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} "
@@ -140,7 +145,9 @@ def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
if not token or not user: if not token or not user:
logger.warning("pushover: missing token or user") logger.warning("pushover: missing token or user")
return False return False
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body} body = "%s: %s" % (notif.title, notif.body)
title = ""
params: dict = {"token": token, "user": user, "title": title, "message": body}
if channel_cfg.get("sound"): if channel_cfg.get("sound"):
params["sound"] = channel_cfg["sound"] params["sound"] = channel_cfg["sound"]
if notif.url: if notif.url:
+68 -21
View File
@@ -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:
@@ -197,9 +207,11 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
# ---- Notification channels (complex, built separately) ---------------- # ---- Notification channels (complex, built separately) ----------------
_METADATA_KEYS = {"type", "owner", "private", "min_level"} _METADATA_KEYS = {"type", "owner", "private", "min_level"}
notif_channels = [] notif_channels = []
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items(): for ch_name, ch_cfg in 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 (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",
@@ -398,10 +427,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
{ {
"id": "dns", "id": "dns",
"title": "Dynamic DNS", "title": "Dynamic DNS",
"description": "nsupdate-based DNS registration — edit raw YAML.", "description": "nsupdate-based DNS registration via nsupdate(8).",
"section_mode": "yaml", "section_mode": "form",
"api_section": "dns", "api_section": "dns",
"fields": [], "fields": [
field("nsupdate_bin", "nsupdate binary", "path",
"Path to the nsupdate binary.", editable=True),
field("rndc_key", "RNDC key file", "path",
"Path to the rndc key file used to authenticate DNS updates.", editable=True),
field("dyndomains", "Dynamic domains", "list",
"Domains updated via nsupdate when a host with dyndns: true reports in.",
editable=True),
],
}, },
{ {
"id": "users", "id": "users",
@@ -475,16 +512,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()),
} }
+1 -1
View File
@@ -185,7 +185,7 @@
/* Slightly larger tap targets in tables */ /* Slightly larger tap targets in tables */
#ntable td, #ntable th { #ntable td, #ntable th {
padding: 4px 6px !important; padding: 4px 6px !important;
font-size: 0.82em !important; font-size: 1.00em !important;
} }
/* Cards on plugin/alerts pages */ /* Cards on plugin/alerts pages */
+14 -1
View File
@@ -74,7 +74,7 @@
background: #e8f0fe; background: #e8f0fe;
color: #1a73e8; color: #1a73e8;
border-radius: 12px; border-radius: 12px;
font-size: 0.85em; font-size: 1.00em;
font-weight: 600; font-weight: 600;
font-family: monospace; font-family: monospace;
} }
@@ -100,6 +100,19 @@
} }
.logo-text { flex: 1; } .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>
+29 -4
View File
@@ -55,7 +55,7 @@
.summary-label { .summary-label {
color: #666; color: #666;
font-size: 0.85em; font-size: 1.00em;
} }
.filters { .filters {
@@ -221,7 +221,7 @@
.alert-duration { .alert-duration {
color: #999; color: #999;
font-size: 0.85em; font-size: 1.00em;
} }
.alert-actions { .alert-actions {
@@ -238,7 +238,7 @@
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.85em; font-size: 1.00em;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
} }
@@ -293,7 +293,7 @@
.refresh-info { .refresh-info {
text-align: center; text-align: center;
color: #999; color: #999;
font-size: 0.85em; font-size: 1.00em;
margin-top: 20px; margin-top: 20px;
padding-top: 20px; padding-top: 20px;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
@@ -305,6 +305,31 @@
text-align: right; text-align: right;
margin-bottom: 15px; 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>
+94 -12
View File
@@ -5,7 +5,68 @@
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" /> <link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
<title>{{ title }}</title> <title>{{ title }}</title>
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %} {% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
<script>
/* Apply saved theme before first paint to avoid flash */
(function() {
try {
var p = localStorage.getItem('hbd_theme') || 'auto';
var dark = p === 'dark' || (p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
} catch(e) {}
})();
</script>
<style> <style>
/* ── Theme variables ── */
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-2: #f8f8f8;
--surface-3: #f5f5f5;
--text: #222222;
--text-2: #333333;
--text-3: #555555;
--text-sec: #666666;
--text-muted: #888888;
--text-dim: #aaaaaa;
--text-ghost: #cccccc;
--border: #e0e0e0;
--border-2: #eeeeee;
--border-3: #f0f0f0;
--border-4: #f5f5f5;
--link: #0066cc;
--nav-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #cccccc;
--shadow-sm: rgba(0,0,0,.08);
--shadow: rgba(0,0,0,.10);
--shadow-nav: rgba(0,0,0,.10);
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #111827;
--surface: #1f2937;
--surface-2: #283447;
--surface-3: #374151;
--text: #e5e7eb;
--text-2: #d1d5db;
--text-3: #9ca3af;
--text-sec: #9ca3af;
--text-muted: #6b7280;
--text-dim: #4b5563;
--text-ghost: #374151;
--border: #374151;
--border-2: #2d3748;
--border-3: #253040;
--border-4: #1e2a38;
--link: #60a5fa;
--nav-bg: #1f2937;
--input-bg: #283447;
--input-border: #4b5563;
--shadow-sm: rgba(0,0,0,.30);
--shadow: rgba(0,0,0,.40);
--shadow-nav: rgba(0,0,0,.40);
}
/* ── Reset / shared baseline ── */ /* ── Reset / shared baseline ── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html { html {
@@ -16,10 +77,11 @@
margin: 0; margin: 0;
padding: 10px; padding: 10px;
padding-top: 60px; padding-top: 60px;
background: #f5f5f5; background: var(--bg);
color: var(--text);
} }
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; } h1 { font-size: 1.5em; color: var(--text-2); margin: 0 0 5px; }
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; } h2 { font-size: 1.1em; color: var(--text-2); margin: 0 0 8px; }
p { margin: 0; } p { margin: 0; }
/* Navigation bar — shared across all pages */ /* Navigation bar — shared across all pages */
@@ -29,9 +91,9 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 200; z-index: 200;
background: #fff; background: var(--nav-bg);
padding: 6px 12px; padding: 6px 12px;
box-shadow: 0 2px 4px rgba(0,0,0,.1); box-shadow: 0 2px 4px var(--shadow-nav);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -42,25 +104,25 @@
.nav a { .nav a {
margin-right: 20px; margin-right: 20px;
text-decoration: none; text-decoration: none;
color: #0066cc; color: var(--link);
font-weight: 500; font-weight: 500;
font-size: 0.9em; font-size: 0.9em;
} }
.nav a:hover { text-decoration: underline; } .nav a:hover { text-decoration: underline; }
.nav a.active { color: #333; font-weight: bold; } .nav a.active { color: var(--text-2); font-weight: bold; }
.nav-user { .nav-user {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
text-decoration: none; text-decoration: none;
color: #333; color: var(--text-2);
font-size: 0.9em; font-size: 0.9em;
font-weight: 500; font-weight: 500;
padding: 4px 8px; padding: 4px 8px;
border-radius: 20px; border-radius: 20px;
transition: background 0.15s; transition: background 0.15s;
} }
.nav-user:hover { background: #f0f4ff; text-decoration: none; } .nav-user:hover { background: var(--surface-2); text-decoration: none; }
.nav-username { .nav-username {
max-width: 0; max-width: 0;
overflow: hidden; overflow: hidden;
@@ -81,7 +143,7 @@
.nav-initials { .nav-initials {
width: 28px; height: 28px; width: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: #0066cc; background: var(--link);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -106,7 +168,7 @@
.nav-hamburger span { .nav-hamburger span {
display: block; display: block;
height: 3px; height: 3px;
background: #555; background: var(--text-muted);
border-radius: 2px; border-radius: 2px;
} }
@@ -118,13 +180,22 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid #eee; border-top: 1px solid var(--border-2);
order: 3; order: 3;
} }
.nav-links.nav-open { display: flex; } .nav-links.nav-open { display: flex; }
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; } .nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
} }
/* ── Global dark-mode: inputs ── */
html[data-theme="dark"] input:not([type=checkbox]):not([type=radio]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text);
}
/* Pending config publish button */ /* Pending config publish button */
.nav-publish-btn { .nav-publish-btn {
background: #e65100; background: #e65100;
@@ -279,6 +350,17 @@
setTimeout(clockTick, delay); setTimeout(clockTick, delay);
} }
/* Keep auto-theme in sync with system setting changes */
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
var pref = localStorage.getItem('hbd_theme') || 'auto';
if (pref === 'auto') {
if (e.matches) { document.documentElement.setAttribute('data-theme', 'dark'); }
else { document.documentElement.removeAttribute('data-theme'); }
}
});
} catch(e) {}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
/* Start the shared tick loop */ /* Start the shared tick loop */
clockTick(); clockTick();
+48 -16
View File
@@ -179,7 +179,7 @@
/* Message styling */ /* Message styling */
#messages { #messages {
font-size: 0.85em; font-size: 1.00em;
line-height: 1.0; line-height: 1.0;
} }
@@ -232,7 +232,7 @@
padding: 3px 7px; padding: 3px 7px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 0.85em; font-size: 1.00em;
color: #333; color: #333;
} }
@@ -288,6 +288,31 @@
} }
#ntable a.host-link { color: inherit; text-decoration: none; } #ntable a.host-link { color: inherit; text-decoration: none; }
#ntable a.host-link:hover { text-decoration: underline; } #ntable a.host-link:hover { text-decoration: underline; }
/* ── Dark mode ── */
html[data-theme="dark"] h1,
html[data-theme="dark"] h2 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] h2,
html[data-theme="dark"] .table-section,
html[data-theme="dark"] .log-section,
html[data-theme="dark"] .log-section-header { background: var(--surface); }
html[data-theme="dark"] .log-section-title { color: var(--text); }
html[data-theme="dark"] #ntable td,
html[data-theme="dark"] #ntable th { border-color: var(--border); }
html[data-theme="dark"] #ntable tr:nth-child(even) { background: var(--surface-2); }
html[data-theme="dark"] #ntable tr:hover { background: #1e3a5f; }
html[data-theme="dark"] #ntable tbody tr.row-warning { background: #3a2800; }
html[data-theme="dark"] #ntable tbody tr.row-critical { background: #3a0a0a; }
html[data-theme="dark"] #ntable tbody tr.row-warning:hover { background: #4a3200; }
html[data-theme="dark"] #ntable tbody tr.row-critical:hover { background: #4a1010; }
html[data-theme="dark"] #messages .log-entry { border-bottom-color: var(--border-3); }
html[data-theme="dark"] .log-ts,
html[data-theme="dark"] .log-service { color: var(--text-muted); }
html[data-theme="dark"] .log-info .log-level { color: var(--text-sec); }
html[data-theme="dark"] .log-filter-bar input,
html[data-theme="dark"] .log-filter-bar select { color: var(--text); }
html[data-theme="dark"] .connection-modal-content { background: var(--surface); color: var(--text); }
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
var cnt = 0; var cnt = 0;
@@ -296,9 +321,15 @@
var c = 0; var c = 0;
var HBD_VERSION = "{{ hbd_version }}"; var HBD_VERSION = "{{ hbd_version }}";
function escHtml(s) {
var d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function hostNameHtml(data) { function hostNameHtml(data) {
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim(); var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
var nameHtml = data.name; var nameHtml = escHtml(data.name);
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) { if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
nameHtml += ' 🥀'; nameHtml += ' 🥀';
} }
@@ -385,11 +416,11 @@
c_critical.innerHTML = ""; c_critical.innerHTML = "";
} }
c_ipv4addr.innerHTML = data.connections[0].addr; c_ipv4addr.innerHTML = escHtml(data.connections[0].addr);
c_ipv4state.innerHTML = data.connections[0].state; c_ipv4state.innerHTML = escHtml(data.connections[0].state);
if (data.connections.length > 1) { if (data.connections.length > 1) {
c_ipv6addr.innerHTML = data.connections[1].addr; c_ipv6addr.innerHTML = escHtml(data.connections[1].addr);
c_ipv6state.innerHTML = data.connections[1].state; c_ipv6state.innerHTML = escHtml(data.connections[1].state);
} }
var table = document.getElementById("ntablebody"); // find table to append to var table = document.getElementById("ntablebody"); // find table to append to
table.appendChild(row); // append row to table table.appendChild(row); // append row to table
@@ -452,7 +483,7 @@
for (var i = 0; i < data.connections.length; i++) { for (var i = 0; i < data.connections.length; i++) {
// Offset by 2 for the warning/critical count columns // Offset by 2 for the warning/critical count columns
name_idx[data.name].cells[3 + i * 4].innerHTML = data.connections[i].addr; name_idx[data.name].cells[3 + i * 4].innerHTML = escHtml(data.connections[i].addr);
name_idx[data.name].cells[6 + i * 4].innerHTML = formatTS( name_idx[data.name].cells[6 + i * 4].innerHTML = formatTS(
data.connections[i].statetime data.connections[i].statetime
); );
@@ -472,7 +503,7 @@
state = '<span class="state-overdue">overdue</span>'; state = '<span class="state-overdue">overdue</span>';
latency = "-"; latency = "-";
} else { } else {
state = "<b>" + data.connections[i].state + "</b>"; state = "<b>" + escHtml(data.connections[i].state) + "</b>";
latency = "-"; latency = "-";
} }
} }
@@ -533,14 +564,14 @@
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds()); + ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
var lvl = (msg.level || "INFO").toLowerCase(); var lvl = (msg.level || "INFO").toLowerCase();
var hostVal = msg.host || ''; var hostVal = msg.host || '';
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '&quot;') + '">'; var html = '<div class="log-entry log-' + escHtml(lvl) + '" data-level="' + escHtml(lvl) + '" data-host="' + escHtml(hostVal) + '">';
html += '<span class="log-ts">' + ts_str + '</span>'; html += '<span class="log-ts">' + ts_str + '</span>';
html += '<span class="log-level">' + (msg.level || "") + '</span>'; html += '<span class="log-level">' + escHtml(msg.level || "") + '</span>';
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>'; if (msg.host) html += '<span class="log-host">' + escHtml(msg.host) + '</span>';
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>'; if (msg.service) html += '<span class="log-service">' + escHtml(msg.service) + '</span>';
html += '<span class="log-msg">' + msg.message + '</span>'; html += '<span class="log-msg">' + escHtml(msg.message) + '</span>';
html += '</div>'; html += '</div>';
msgs.insertAdjacentHTML("afterbegin", html); msgs.insertAdjacentHTML(state.history ? "beforeend" : "afterbegin", html);
applyLogFilters(); applyLogFilters();
} }
cnt++; cnt++;
@@ -596,7 +627,7 @@
<tbody id="ntablebody"> <tbody id="ntablebody">
{% for host in hosts %} {% for host in hosts %}
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}"> <tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
<td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.raw_name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td> <td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
<td style="text-align: center; color: #ff9800; font-weight: bold;"> <td style="text-align: center; color: #ff9800; font-weight: bold;">
{%- set warning_unacked = host.alert_warning_unacked -%} {%- set warning_unacked = host.alert_warning_unacked -%}
{%- set warning_acked = host.alert_warning_acked -%} {%- set warning_acked = host.alert_warning_acked -%}
@@ -640,6 +671,7 @@
<option value="warning">WARNING</option> <option value="warning">WARNING</option>
<option value="critical">CRITICAL</option> <option value="critical">CRITICAL</option>
<option value="recover">RECOVER</option> <option value="recover">RECOVER</option>
<option value="unknown">UNKNOWN</option>
</select> </select>
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" /> <input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
</div> </div>
+2 -2
View File
@@ -6,12 +6,12 @@
<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 %} {% 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">&#9888; 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">&#9888; Publish Config</button>
{% endif %} {% endif %}
<div class="nav-pie" title="Host alert status"> <div class="nav-pie" title="Host alert status">
+141 -10
View File
@@ -218,7 +218,7 @@
.plugin-label { .plugin-label {
font-weight: 600; font-weight: 600;
font-size: 0.85em; font-size: 1.00em;
color: #444; color: #444;
min-width: 140px; min-width: 140px;
} }
@@ -238,7 +238,7 @@
.data-table { .data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85em; font-size: 1.00em;
background: #fff; background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-radius: 4px; border-radius: 4px;
@@ -261,7 +261,7 @@
.data-table th.center { text-align: center; } .data-table th.center { text-align: center; }
.data-table td { .data-table td {
padding: 6px 10px; /* padding: 6px 10px; */
border-top: 1px solid #e8e8e8; border-top: 1px solid #e8e8e8;
color: #333; color: #333;
} }
@@ -369,7 +369,7 @@
text-align: center; text-align: center;
padding: 12px; padding: 12px;
color: #aaa; color: #aaa;
font-size: 0.85em; font-size: 1.00em;
} }
.error { .error {
@@ -379,7 +379,7 @@
margin: 8px 0; margin: 8px 0;
border-radius: 3px; border-radius: 3px;
color: #c62828; color: #c62828;
font-size: 0.85em; font-size: 1.00em;
} }
/* ── Scrollbar ──────────────────────────────────────────────── */ /* ── Scrollbar ──────────────────────────────────────────────── */
@@ -394,7 +394,7 @@
padding: 12px 16px; padding: 12px 16px;
background: #fafafa; background: #fafafa;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
font-size: 0.85em; font-size: 1.00em;
} }
.info-meta { .info-meta {
display: grid; display: grid;
@@ -411,7 +411,48 @@
} }
.info-note { color: #888; font-style: italic; } .info-note { color: #888; font-style: italic; }
.info-loading { color: #bbb; font-style: italic; } .info-loading { color: #bbb; font-style: italic; }
.threshold-covers { font-size: 0.85em; color: #777; font-style: italic; } .threshold-covers { font-size: 1.00em; color: #777; 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>
@@ -873,7 +914,7 @@
let html = ''; let html = '';
switch (pluginName) { switch (pluginName) {
case 'os_info': html = renderOsInfoTable(cached.data); break; case 'os_info': html = renderOsInfoTable(cached.data); break;
case 'cpu_monitor': html = renderCpuTable(cached.data); break; case 'cpu_monitor': html = renderCpuTable(hostname, cached.data); break;
case 'memory_monitor': html = renderMemoryTable(cached.data); break; case 'memory_monitor': html = renderMemoryTable(cached.data); break;
case 'disk_monitor': html = renderDiskTables(cached.data); break; case 'disk_monitor': html = renderDiskTables(cached.data); break;
case 'network_monitor':html = renderNetworkTables(cached.data); break; case 'network_monitor':html = renderNetworkTables(cached.data); break;
@@ -885,6 +926,10 @@
html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`; html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`;
body.innerHTML = html; body.innerHTML = html;
if (pluginName === 'cpu_monitor') {
fetchCpuHistory(hostname).then(samples => renderCpuChart(hostname, samples)).catch(() => {});
}
} }
// ── Per-plugin renderers ──────────────────────────────────────────────── // ── Per-plugin renderers ────────────────────────────────────────────────
@@ -907,7 +952,92 @@
return html; return html;
} }
function renderCpuTable(d) { async function fetchCpuHistory(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/cpu_monitor?limit=100`);
if (!r.ok) return [];
const json = await r.json();
return json.samples || [];
}
function renderCpuChart(hostname, samples) {
const el = document.getElementById(`cpu-chart-${hostname}`);
if (!el || !samples.length) 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 W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
const tRange = tMax - tMin || 1;
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
// Auto-scale Y axis with 10% padding, clamped to [0, 100]
const vMin = Math.min(...pts.map(p => p.v));
const vMax = Math.max(...pts.map(p => p.v));
const vRange = vMax - vMin || 1;
const vPad = Math.max(vRange * 0.1, 1);
const yLow = Math.max(0, vMin - vPad);
const yHigh = Math.min(100, vMax + vPad);
const yRange = yHigh - yLow || 1;
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
// Build polyline points and filled area path
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 strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
// Compute nice tick step for ~3-5 grid lines
const rawStep = yRange / 4;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10;
const tickStart = Math.ceil(yLow / niceStep) * niceStep;
let gridLines = '';
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
const yy = y(v).toFixed(1);
const label = Number.isInteger(v) ? v : v.toFixed(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>`;
}
// X-axis time labels
const fmt = ts => {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const xLabels = `
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text>
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`;
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
style="width:100%;height:${H}px;display:block;">
<defs>
<clipPath id="cpu-clip-${hostname}">
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
</clipPath>
</defs>
${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 + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
<g clip-path="url(#cpu-clip-${hostname})">
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
</g>
${xLabels}
</svg>`;
}
function renderCpuTable(hostname, d) {
const KEYS = [ const KEYS = [
['cpu_percent', 'CPU Usage', 'bar'], ['cpu_percent', 'CPU Usage', 'bar'],
['load_1min', 'Load (1 min)', 'num'], ['load_1min', 'Load (1 min)', 'num'],
@@ -925,7 +1055,8 @@
]; ];
const handled = new Set(KEYS.map(r => r[0])); const handled = new Set(KEYS.map(r => r[0]));
let html = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>'; let html = `<div id="cpu-chart-${hostname}" style="margin-bottom:8px;"></div>`;
html += '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
for (const [k, label, fmt] of KEYS) { for (const [k, label, fmt] of KEYS) {
if (!(k in d)) continue; if (!(k in d)) continue;
const v = d[k]; const v = d[k];
+89 -14
View File
@@ -96,7 +96,7 @@
border-radius: 4px; border-radius: 4px;
background: #f44336; background: #f44336;
color: #fff; color: #fff;
font-size: 0.85em; font-size: 1.00em;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
transition: background 0.15s; transition: background 0.15s;
@@ -157,7 +157,7 @@
gap: 6px; gap: 6px;
padding: 4px 12px; padding: 4px 12px;
border-radius: 16px; border-radius: 16px;
font-size: 0.85em; font-size: 1.00em;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
} }
@@ -240,13 +240,62 @@
} }
.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; }
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; } .btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
.btn-sm-del:hover { background: #fce4ec; } .btn-sm-del:hover { background: #fce4ec; }
/* ---- Theme picker ---- */
.theme-btns { display: flex; gap: 6px; }
.theme-btn {
padding: 5px 14px;
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
background: var(--surface-3, #f5f5f5);
color: var(--text-sec, #666);
cursor: pointer;
font-size: .88em;
font-family: inherit;
}
.theme-btn:hover { border-color: var(--link, #0066cc); color: var(--link, #0066cc); }
.theme-btn.active { background: var(--link, #0066cc); color: #fff; border-color: var(--link, #0066cc); }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .profile-card { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
html[data-theme="dark"] .profile-name { color: var(--text); }
html[data-theme="dark"] .profile-username { color: var(--text-sec); }
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
html[data-theme="dark"] .badge-user { background: var(--surface-3); 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"] .settings-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .settings-label { color: var(--text-sec); }
html[data-theme="dark"] .settings-value { color: var(--text); }
html[data-theme="dark"] .settings-empty { color: var(--text-dim); }
html[data-theme="dark"] .edit-section h4 { color: var(--text); border-bottom-color: var(--border); }
html[data-theme="dark"] .edit-field label { color: var(--text-sec); }
html[data-theme="dark"] .edit-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
html[data-theme="dark"] .channel-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .channel-name { color: var(--text); }
html[data-theme="dark"] .ch-picker-label { color: var(--text-sec); }
html[data-theme="dark"] .ch-chip.selected { background: #1a3255; color: #60a5fa; }
html[data-theme="dark"] .ch-chip.available { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .ch-chip.available:hover { background: var(--border); color: var(--link); }
html[data-theme="dark"] .my-ch-card { border-color: var(--border); }
html[data-theme="dark"] .my-ch-header { background: var(--surface-2); border-bottom-color: var(--border); }
html[data-theme="dark"] .my-ch-name { color: var(--text); }
html[data-theme="dark"] .host-chip.owner { background: #0d2e17; color: #66bb6a; }
html[data-theme="dark"] .host-chip.manager { background: #0d1f40; color: #64b5f6; }
html[data-theme="dark"] .host-chip.monitor { background: #1e0d30; color: #ba68c8; }
html[data-theme="dark"] .no-hosts { color: var(--text-dim); }
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
/* ---- Channel modal (for My Channels CRUD) ---- */ /* ---- Channel modal (for My Channels CRUD) ---- */
.ch-modal-overlay { .ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4); position: fixed; inset: 0; background: rgba(0,0,0,.4);
@@ -415,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 %}
@@ -423,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>
@@ -463,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>
@@ -477,6 +520,19 @@
</div> </div>
{% endif %} {% endif %}
<!-- Appearance -->
<div class="section">
<h2>Appearance</h2>
<div class="settings-row">
<span class="settings-label">Theme</span>
<div class="theme-btns">
<button class="theme-btn" data-theme-val="auto" onclick="setTheme('auto')">Auto</button>
<button class="theme-btn" data-theme-val="light" onclick="setTheme('light')">Light</button>
<button class="theme-btn" data-theme-val="dark" onclick="setTheme('dark')">Dark</button>
</div>
</div>
</div>
<!-- Host access --> <!-- Host access -->
<div class="section"> <div class="section">
<h2>Host Access</h2> <h2>Host Access</h2>
@@ -523,6 +579,28 @@
</div> </div>
<script> <script>
// ---- Theme ----
function applyTheme(pref) {
var dark = pref === 'dark' ||
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
else { document.documentElement.removeAttribute('data-theme'); }
}
function setTheme(pref) {
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
applyTheme(pref);
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.themeVal === pref);
});
}
(function() {
var pref = 'auto';
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.themeVal === pref);
});
})();
// ---- Identity ---- // ---- Identity ----
async function saveIdentity() { async function saveIdentity() {
const full_name = document.getElementById('profile-fullname').value; const full_name = document.getElementById('profile-fullname').value;
@@ -659,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 {
@@ -670,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 || '';
@@ -689,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);
+123 -18
View File
@@ -31,7 +31,7 @@
padding: 6px 10px; padding: 6px 10px;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
font-size: 0.85em; font-size: 1.00em;
color: #444; color: #444;
margin-bottom: 2px; margin-bottom: 2px;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
@@ -199,7 +199,7 @@
.channel-field { .channel-field {
display: flex; display: flex;
padding: 5px 14px; padding: 5px 14px;
font-size: 0.85em; font-size: 1.00em;
border-bottom: 1px solid #f5f5f5; border-bottom: 1px solid #f5f5f5;
gap: 12px; gap: 12px;
} }
@@ -350,7 +350,7 @@
.yaml-editor:focus { border-color: #0066cc; outline: none; } .yaml-editor:focus { border-color: #0066cc; outline: none; }
/* ---- Button styles ---- */ /* ---- Button styles ---- */
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; } .btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 1.00em; cursor: pointer; }
.btn-primary { background: #0066cc; color: #fff; } .btn-primary { background: #0066cc; color: #fff; }
.btn-primary:hover { background: #0055aa; } .btn-primary:hover { background: #0055aa; }
.btn-success { background: #2a7a2a; color: #fff; } .btn-success { background: #2a7a2a; color: #fff; }
@@ -440,7 +440,7 @@
} }
.mpick-col:first-child { border-right: 1px solid #eee; } .mpick-col:first-child { border-right: 1px solid #eee; }
.mpick-item { .mpick-item {
padding: 5px 10px; font-size: 0.85em; cursor: pointer; padding: 5px 10px; font-size: 1.00em; cursor: pointer;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #f8f8f8; gap: 4px; border-bottom: 1px solid #f8f8f8; gap: 4px;
} }
@@ -456,6 +456,67 @@
display: flex; justify-content: flex-end; background: #f8f8f8; display: flex; justify-content: flex-end; background: #f8f8f8;
} }
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; } .mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .sidebar-nav a { color: var(--text-sec); }
html[data-theme="dark"] .sidebar-nav a:hover { background: var(--surface-3); color: var(--link); }
html[data-theme="dark"] .sidebar-nav a.active { background: #1a3255; color: #60a5fa; }
html[data-theme="dark"] .sidebar-toggle { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .sidebar-nav { background: var(--surface); }
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 4px var(--shadow); }
html[data-theme="dark"] .section-header { border-bottom-color: var(--border); }
html[data-theme="dark"] .section-title { color: var(--text-2); }
html[data-theme="dark"] .section-desc { color: var(--text-muted); }
html[data-theme="dark"] .section-footer { border-top-color: var(--border-3); }
html[data-theme="dark"] .field-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .field-label { color: var(--text-sec); }
html[data-theme="dark"] .field-value { color: var(--text); }
html[data-theme="dark"] .field-desc { color: var(--text-muted); }
html[data-theme="dark"] .val-boolean.on { background: #0d2e17; color: #66bb6a; }
html[data-theme="dark"] .val-boolean.off { background: #2e0d0d; color: #ef9a9a; }
html[data-theme="dark"] .val-tag { background: #1a2d5a; color: #7aa8f0; }
html[data-theme="dark"] .val-empty { color: var(--text-dim); }
html[data-theme="dark"] .val-masked { color: var(--text-muted); }
html[data-theme="dark"] .mini-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
html[data-theme="dark"] .mini-table td { border-bottom-color: var(--border-3); color: var(--text); }
html[data-theme="dark"] .mini-table tbody tr:hover { background: var(--surface-2); }
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .channel-card { border-color: var(--border); }
html[data-theme="dark"] .channel-header { background: var(--surface-2); border-bottom-color: var(--border); }
html[data-theme="dark"] .channel-name-text { color: var(--text); }
html[data-theme="dark"] .channel-field { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .channel-field-label { color: var(--text-muted); }
html[data-theme="dark"] .channel-field-value { color: var(--text); }
html[data-theme="dark"] .thresh-cfg-card { border-color: var(--border); }
html[data-theme="dark"] .thresh-cfg-header { background: var(--surface-2); border-bottom-color: var(--border); }
html[data-theme="dark"] .thresh-cfg-name-label { color: #60a5fa; }
html[data-theme="dark"] .crud-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
html[data-theme="dark"] .crud-table td { border-bottom-color: var(--border-3); color: var(--text); }
html[data-theme="dark"] .yaml-editor { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
html[data-theme="dark"] .pending-banner { background: #2d2400; border-color: #a08020; }
html[data-theme="dark"] .pending-banner .pending-msg { color: #e8c840; }
html[data-theme="dark"] .modal-box,
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
html[data-theme="dark"] .modal-box h3,
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
html[data-theme="dark"] .backup-row { border-bottom-color: var(--border-3); }
html[data-theme="dark"] .mpick-display { background: var(--input-bg); border-color: var(--input-border); }
html[data-theme="dark"] .mpick-display:hover { border-color: var(--link); background: var(--surface-2); }
html[data-theme="dark"] .mpick-tag { background: #1a2d5a; color: #7aa8f0; }
html[data-theme="dark"] .mpick-more,
html[data-theme="dark"] .mpick-empty { color: var(--text-muted); }
html[data-theme="dark"] .mpick-panel { background: var(--surface); border-color: var(--border); }
html[data-theme="dark"] .mpick-panel-header { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
html[data-theme="dark"] .mpick-item { border-bottom-color: var(--border-4); color: var(--text); }
html[data-theme="dark"] .mpick-item-avail:hover { background: #0d2e17; }
html[data-theme="dark"] .mpick-item-sel:hover { background: #2e0d0d; }
html[data-theme="dark"] .mpick-panel-footer { background: var(--surface-2); border-top-color: var(--border); }
html[data-theme="dark"] .mpick-none { color: var(--text-dim); }
</style> </style>
<body> <body>
@@ -511,11 +572,12 @@
<option value="CRITICAL">CRITICAL only</option> <option value="CRITICAL">CRITICAL only</option>
</select> </select>
</div> </div>
{% if not current_user or current_user.admin %}
<div class="ch-form-row"> <div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"> <label>Owner <span style="font-weight:normal;color:#888">(empty = global)</span></label>
<input type="checkbox" id="ch-private"> Private — visible only to you <input type="text" id="ch-owner" placeholder="(global)" autocomplete="off">
</label>
</div> </div>
{% endif %}
<div id="ch-modal-status" class="ch-status"></div> <div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer"> <div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button> <button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
@@ -533,8 +595,10 @@
{% for section in sections %} {% for section in sections %}
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a> <a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
{% endfor %} {% endfor %}
{% if not current_user or current_user.admin %}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;"> <hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a> <a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
{% endif %}
</div> </div>
</nav> </nav>
@@ -648,12 +712,18 @@
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td> <td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
{% if h.is_owner %}
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td> <td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td> <td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td> <td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
{% else %}
<td><span class="val-tag">{{ h.owner | e }}</span></td>
<td>{% for m in h.managers %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
<td>{% for m in h.monitors %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
{% endif %}
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td> <td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td> <td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td> <td>{% if h.is_owner %}<button class="btn-danger" onclick="toggleDeleteRow(this)"></button>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -687,12 +757,13 @@
<span class="channel-name-text">{{ ch.name | e }}</span> <span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span> <span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %} {% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %} {% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
{% if ch.editable %}
<span class="channel-header-actions"> <span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button> <button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button> <button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span> </span>
{% endif %}
</div> </div>
<div class="channel-fields"> <div class="channel-fields">
{% for f in ch.fields %} {% for f in ch.fields %}
@@ -729,19 +800,27 @@
{% endfor %} {% endfor %}
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0"> <div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
{% for tc in section.threshold_configs %} {% for tc in section.threshold_configs %}
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"> <div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"{% if not tc.editable %} data-readonly="true"{% endif %}>
<div class="thresh-cfg-header"> <div class="thresh-cfg-header">
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span> <span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
{% if tc.name != 'default' %} {% if (not current_user or current_user.admin) and tc.name != 'default' %}
<input type="text" class="field-input thresh-owner" value="{{ tc.owner or '' }}"
placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">
{% elif tc.owner %}
<span class="ch-owner-badge" style="margin-left:10px">{{ tc.owner | e }}</span>
{% endif %}
{% if tc.editable and tc.name != 'default' %}
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button> <button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
{% endif %} {% endif %}
</div> </div>
<fieldset {% if not tc.editable %}disabled{% endif %} style="border:none;margin:0;padding:0;min-width:0">
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<table class="crud-table thresh-metric-table"> <table class="crud-table thresh-metric-table">
<thead><tr> <thead><tr>
<th>Metric path</th><th>Op</th> <th>Metric path</th><th>Op</th>
<th>Warning</th><th>Critical</th> <th>Warning</th><th>Critical</th>
<th>Hysteresis</th><th>Count</th> <th>Hysteresis</th><th>Count</th>
<th title="Grace period (s) — overrides global; empty = use global">Grace</th>
<th style="max-width:160px">Display</th> <th style="max-width:160px">Display</th>
<th>En</th><th></th> <th>En</th><th></th>
</tr></thead> </tr></thead>
@@ -766,6 +845,9 @@
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td> value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" <td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
value="{{ m.count if m.count is not none else 1 }}"></td> value="{{ m.count if m.count is not none else 1 }}"></td>
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px"
value="{{ m.grace if m.grace is not none else '' }}"
placeholder="(global)"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px" <td><input type="text" class="field-input thresh-display" style="width:150px"
value="{{ m.display | e }}" placeholder="(default)"></td> value="{{ m.display | e }}" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" <td style="text-align:center"><input type="checkbox" class="thresh-enabled"
@@ -780,6 +862,7 @@
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px" <button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button> onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div> </div>
</fieldset>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -816,6 +899,11 @@
<input type="number" class="field-input" <input type="number" class="field-input"
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}" data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}"> value="{{ f.raw if f.raw is not none else '' }}">
{% elif f.type == 'list' %}
<input type="text" class="field-input"
data-key="{{ f.key }}" data-type="list" data-section="{{ section.api_section }}"
value="{{ f.value | join(', ') if f.value else '' }}"
placeholder="comma-separated">
{% else %} {% else %}
<input type="text" class="field-input" <input type="text" class="field-input"
data-key="{{ f.key }}" data-section="{{ section.api_section }}" data-key="{{ f.key }}" data-section="{{ section.api_section }}"
@@ -858,6 +946,7 @@
const _allChannels = {{ all_channel_names | tojson }}; const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }}; const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }}; const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
const _isAdmin = {{ 'true' if (not current_user or current_user.admin) else 'false' }};
// ---- Channel CRUD ---- // ---- Channel CRUD ----
let _channelSchemas = {}; let _channelSchemas = {};
@@ -911,7 +1000,8 @@
document.getElementById('ch-type').value = ''; document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = ''; document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING'; document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false; const ownerInp = document.getElementById('ch-owner');
if (ownerInp) ownerInp.value = '';
if (name) { if (name) {
// Load existing channel data via API // Load existing channel data via API
@@ -923,7 +1013,7 @@
document.getElementById('ch-type').value = ch.type; document.getElementById('ch-type').value = ch.type;
onChTypeChange(); onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING'; document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('ch-private').checked = ch.private || false; if (ownerInp) ownerInp.value = ch.owner || '';
(ch.fields || []).forEach(f => { (ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key); const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || ''; if (inp) inp.value = f.value || '';
@@ -942,14 +1032,15 @@
const name = document.getElementById('ch-name').value.trim(); const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value; const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value; const minLevel = document.getElementById('ch-min-level').value;
const isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status'); const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = ''; statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; } if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; } 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 };
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) body.owner = ownerInp.value.trim();
if (_channelSchemas[type]) { if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => { (_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key); const inp = document.getElementById('chf-' + sf.key);
@@ -1019,6 +1110,8 @@
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') { } else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
const v = parseInt(el.value, 10); const v = parseInt(el.value, 10);
_staged[apiSection][key] = isNaN(v) ? null : v; _staged[apiSection][key] = isNaN(v) ? null : v;
} else if (el.dataset.type === 'list') {
_staged[apiSection][key] = el.value.split(',').map(s => s.trim()).filter(Boolean);
} else { } else {
_staged[apiSection][key] = el.value; _staged[apiSection][key] = el.value;
} }
@@ -1105,8 +1198,11 @@
watch: row.querySelector('.host-watch').checked, watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked, dyndns: row.querySelector('.host-dyndns').checked,
}; };
const owner = row.querySelector('.host-owner').value.trim(); const ownerInput = row.querySelector('.host-owner');
if (owner) entry.owner = owner; if (ownerInput) {
const owner = ownerInput.value.trim();
if (owner) entry.owner = owner;
}
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value); const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers; if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value); const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
@@ -1467,6 +1563,7 @@
const crit = row.querySelector('.thresh-crit')?.value; const crit = row.querySelector('.thresh-crit')?.value;
const hyst = row.querySelector('.thresh-hyst')?.value; const hyst = row.querySelector('.thresh-hyst')?.value;
const count = row.querySelector('.thresh-count')?.value; const count = row.querySelector('.thresh-count')?.value;
const grace = row.querySelector('.thresh-grace')?.value;
const display = row.querySelector('.thresh-display')?.value || ''; const display = row.querySelector('.thresh-display')?.value || '';
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true; const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
const entry = { operator: op, enabled: enabled }; const entry = { operator: op, enabled: enabled };
@@ -1474,6 +1571,7 @@
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit); if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst); if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10); if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
if (grace !== '' && grace !== undefined) entry.grace = parseFloat(grace);
if (display) entry.display = display; if (display) entry.display = display;
metrics[metric] = entry; metrics[metric] = entry;
}); });
@@ -1482,10 +1580,15 @@
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId); const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => { cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
if (card.dataset.readonly === 'true') return;
const configName = card.dataset.configName const configName = card.dataset.configName
|| (card.querySelector('.new-config-name')?.value || '').trim(); || (card.querySelector('.new-config-name')?.value || '').trim();
if (!configName) return; if (!configName) return;
configs[configName] = readMetrics(card); const ownerInp = card.querySelector('.thresh-owner');
configs[configName] = {
owner: ownerInp ? ownerInp.value.trim() : '',
metrics: readMetrics(card),
};
}); });
_staged['thresholds'] = configs; _staged['thresholds'] = configs;
@@ -1525,6 +1628,7 @@
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td> <td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td> <td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td> <td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px" placeholder="(global)"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td> <td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td> <td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()"></button></td>`; <td><button class="btn-danger" onclick="this.closest('tr').remove()"></button></td>`;
@@ -1538,6 +1642,7 @@
card.innerHTML = ` card.innerHTML = `
<div class="thresh-cfg-header"> <div class="thresh-cfg-header">
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px"> <input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
${_isAdmin ? '<input type="text" class="field-input thresh-owner" placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">' : ''}
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button> <button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
</div> </div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
+4
View File
@@ -1554,6 +1554,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"
+84 -3
View File
@@ -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
@@ -232,6 +237,23 @@ def _make_timer_callbacks(uname, host, ctx):
return on_overdue, on_unknown return on_overdue, on_unknown
def _make_plugin_stale_callback(uname, ctx):
"""Return an async callback that clears stale plugin data and its alerts."""
msg_to_websockets = ctx.get("msg_to_websockets")
async def on_plugin_stale(host, plugin_name):
host.plugin_data.pop(plugin_name, None)
stale_keys = [k for k in host.alert_states if k.startswith(f"{plugin_name}.")]
for k in stale_keys:
del host.alert_states[k]
eventlog(uname, "INFO", f"plugin data stale: {plugin_name}")
if msg_to_websockets:
msg_to_websockets("plugin_stale", {"host": uname, "plugin": plugin_name})
msg_to_websockets("host", host.stateinfo())
return on_plugin_stale
def restore_connection_timers(hbdclass, ctx): def restore_connection_timers(hbdclass, ctx):
"""Restore overdue timers for all loaded connections after a pickle restore. """Restore overdue timers for all loaded connections after a pickle restore.
@@ -249,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
@@ -283,6 +310,10 @@ 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)
@@ -333,6 +364,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Use new config function to check dyndns # Use new config function to check dyndns
dyndnshosts = config_mod.get_dyndnshosts(cfg) dyndnshosts = config_mod.get_dyndnshosts(cfg)
host.dyn = uname in dyndnshosts host.dyn = uname in dyndnshosts
watchhosts = config_mod.get_watchhosts(cfg)
host.watched = uname in watchhosts
# Apply user-access settings from config # Apply user-access settings from config
access = config_mod.get_host_access(cfg, uname) access = config_mod.get_host_access(cfg, uname)
host.apply_access(access["owner"], access["managers"], access["monitors"]) host.apply_access(access["owner"], access["managers"], access["monitors"])
@@ -366,16 +399,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)
# Set the stale timer. Prefer the interval the client declares: it is
# the true collection cadence and is correct from the very first
# 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:
@@ -430,6 +497,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(
@@ -437,11 +505,24 @@ 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)
# 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.
for pname in list(host.plugin_timers):
host.cancel_plugin_timer(pname)
host.plugin_data.clear()
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 —
+5 -3
View File
@@ -85,13 +85,15 @@ async def handler(request):
except Exception as e: except Exception as e:
logger.error("Error sending initial hosts: %s", e) logger.error("Error sending initial hosts: %s", e)
# Send recent messages, filtered to hosts this user may see # Send recent messages newest-first so the client can append them in
# display order without reordering on arrival (tagged history=True so
# the client knows to append rather than prepend).
if data.msgs: if data.msgs:
try: try:
for m in 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})) await ws.send_str(json.dumps({"type": "message", "data": m, "history": True}))
except Exception as e: except Exception as e:
logger.error("Error sending initial messages: %s", e) logger.error("Error sending initial messages: %s", e)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.3.6" version = "5.3.12"
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"
+16 -1
View File
@@ -5,9 +5,23 @@ uv version --bump patch
VER=$(uv version --short) VER=$(uv version --short)
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py
sed -i".bak" "s/\*\*Package:\*\* \`hbd\` v[0-9.]*/\*\*Package:\*\* \`hbd\` v$VER/" README.md
# Update CHANGELOG.md with commits since last tag
LASTTAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
ADDED=$(git log "${LASTTAG:+$LASTTAG..}HEAD" --pretty="%s" | grep "^feat:" | sed 's/^feat: /- /')
FIXED=$(git log "${LASTTAG:+$LASTTAG..}HEAD" --pretty="%s" | grep "^fix:" | sed 's/^fix: /- /')
{
printf "## [%s]\n" "$VER"
[ -n "$ADDED" ] && printf "\n### Added\n%s\n" "$ADDED"
[ -n "$FIXED" ] && printf "\n### Fixed\n%s\n" "$FIXED"
printf "\n---\n\n"
} > /tmp/changelog_entry.txt
sed -i".bak" "4r /tmp/changelog_entry.txt" CHANGELOG.md
rm /tmp/changelog_entry.txt CHANGELOG.md.bak
# commit pyproject.toml # commit pyproject.toml
git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py README.md CHANGELOG.md
git push git push
# tag version # tag version
git tag -a v$VER -m "Version $VER" git tag -a v$VER -m "Version $VER"
@@ -15,3 +29,4 @@ git push --tags
rm hbd/__init__.py.bak rm hbd/__init__.py.bak
rm scripts/hbc_mini.py.bak rm scripts/hbc_mini.py.bak
rm README.md.bak
+14 -7
View File
@@ -667,6 +667,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 +782,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");
} }
@@ -789,14 +791,14 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
* Plugin: memory_monitor * Plugin: memory_monitor
* Linux: /proc/meminfo * Linux: /proc/meminfo
* FreeBSD: sysctl vm.stats.vm.* * FreeBSD: sysctl vm.stats.vm.*
* NetBSD: sysctl vm.uvmexp (struct uvmexp) * NetBSD: sysctl vm.uvmexp (struct uvmexp_sysctl)
* ============================================================ */ * ============================================================ */
/* emit the common kvdict fields and send */ /* emit the common kvdict fields and send */
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 +821,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 +866,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,16 +892,16 @@ 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__)
static void plugin_memory_monitor(conn_t *c, const config_t *cfg) { static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
(void)cfg; (void)cfg;
struct uvmexp uvm; struct uvmexp_sysctl uvm;
size_t len = sizeof(uvm); size_t len = sizeof(uvm);
int mib[2] = {CTL_VM, VM_UVMEXP}; int mib[2] = {CTL_VM, VM_UVMEXP2};
if (sysctl(mib, 2, &uvm, &len, NULL, 0) != 0) return; if (sysctl(mib, 2, &uvm, &len, NULL, 0) != 0) return;
long long ps = uvm.pagesize; long long ps = uvm.pagesize;
@@ -910,7 +913,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 +965,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 +1071,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 +1130,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 +1200,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");
} }
+3 -3
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh # updated by scripts/bumpminor.sh
__version__ = "5.3.6" __version__ = "5.3.12"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
@@ -955,7 +955,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 +968,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
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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,
)
+126
View File
@@ -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"
}
+247
View File
@@ -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": {}}})
+25
View File
@@ -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"}}) == {}
+16 -24
View File
@@ -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
View File
@@ -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"}