Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0443293e9 | |||
| 39670f4e63 | |||
| 2e88ee2269 | |||
| 2ef7d473c3 | |||
| 862a9cdea0 | |||
| 9351938b15 | |||
| b6ef2fe065 | |||
| d5d2f066b3 | |||
| d9563392c3 | |||
| 5f090b9d96 | |||
| 3cc1d92eb4 | |||
| 2ddba203df | |||
| 8a1f412d1d | |||
| 40c44f53f1 | |||
| a6fe8546a8 | |||
| e56660454d | |||
| 9cbf0ecb13 | |||
| 313bbd37ac | |||
| f7320644f3 | |||
| 76e11b92f2 | |||
| d39c0da5fe | |||
| 832b9d04d8 | |||
| 44d5f15a67 | |||
| 37b8e35a26 | |||
| fa317a3b78 | |||
| 8729fe7038 | |||
| f4231dd5f3 | |||
| c47576637f | |||
| 2b9523ec28 | |||
| 610ad0af30 | |||
| 69b5b410ed | |||
| 8b2b0fd9d0 | |||
| 756b2323be | |||
| 6e7156b42d | |||
| 928035df50 | |||
| 0f90be659e | |||
| 4160e34a96 | |||
| 6430d2ddf3 | |||
| 4b87a90e76 | |||
| 450814daca | |||
| e7786ac5da | |||
| fed71d97d6 | |||
| ba96da9622 | |||
| 7f17ddc2ff | |||
| 7750c5a303 | |||
| e58530df7d | |||
| fe7143759c | |||
| 236b40cfe4 | |||
| 4e5bafd26c | |||
| 817ae064af | |||
| a00282913b | |||
| d699a29fa9 | |||
| 4ce7eacfdd | |||
| 1cefc2676e | |||
| 668a135e53 | |||
| 59e256a042 | |||
| 708508157f | |||
| f67fa9baff | |||
| 588eb2a792 | |||
| b907343e36 | |||
| e50a3996ae | |||
| e1056a0365 | |||
| 1dbe0f8e64 | |||
| 12e8812070 | |||
| 9b5d8ac9b1 | |||
| 500d256d76 | |||
| a7a45bf8c3 | |||
| 3e9b052f71 | |||
| 7444262985 | |||
| 3401cc0dbb | |||
| ab0132a38d | |||
| 9e389736f8 | |||
| b64a2a9313 | |||
| a52744a448 | |||
| 5e2b04b811 | |||
| 8e07b09d7e | |||
| 653e018e4f | |||
| c7326da7d9 | |||
| 0426a75d8c | |||
| 539f25d877 | |||
| 3e3099fc6d | |||
| c9f15a3f1c | |||
| 6e396ad760 | |||
| 2800de0b4a | |||
| 15f7e6a64d | |||
| 9768d13b88 | |||
| 8640d731aa | |||
| de81751e59 | |||
| 60c692cefc | |||
| 9a0baf3c78 | |||
| 55bdb9593a | |||
| 2009626fb4 | |||
| 18769afd37 | |||
| 31db5cf35e | |||
| 326f53f23d | |||
| 4f9bc8c868 | |||
| 259b4a3594 | |||
| 8646f68957 | |||
| a4a6c1e3d9 | |||
| 0e8250362e | |||
| 2f5da9fc5e | |||
| 87aeec5999 | |||
| f24500a6b5 | |||
| a7bb183222 | |||
| 8207cd7b5f | |||
| 11f1eefa8c | |||
| 62f496e9f8 | |||
| aef9e7769b | |||
| 58c2b9d996 | |||
| 2e8bcb630d | |||
| 338711181b | |||
| 43487f17e7 |
@@ -10,36 +10,48 @@ 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
|
|
||||||
# uses: actions/setup-python@v5
|
|
||||||
# with:
|
|
||||||
# python-version: '3.11'
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
# Use a generic run step for FreeBSD if actions/setup-python
|
|
||||||
# fails in restricted environments.
|
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
python3 -m ensurepip --upgrade
|
python3 -m ensurepip --upgrade
|
||||||
|
|
||||||
- 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
|
||||||
@@ -48,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 }}"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.flake8
|
.flake8
|
||||||
.venv/
|
.venv/
|
||||||
|
.continue/
|
||||||
test/
|
test/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
@@ -12,3 +13,6 @@ dist/
|
|||||||
ssl/
|
ssl/
|
||||||
uv.lock
|
uv.lock
|
||||||
.hb.yaml
|
.hb.yaml
|
||||||
|
.superpowers/
|
||||||
|
rndc-key
|
||||||
|
docs/superpowers/
|
||||||
|
|||||||
+457
@@ -0,0 +1,457 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here, organized by release.
|
||||||
|
|
||||||
|
## [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
|
||||||
@@ -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) |
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2002 - 2026 Andreas Wrede
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -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.
|
||||||
@@ -53,6 +53,17 @@ See [User Management](USERS.md) for full authentication documentation.
|
|||||||
|--------|------|-------------|------|
|
|--------|------|-------------|------|
|
||||||
| `GET` | `/api/0/users` | List all users | Admin |
|
| `GET` | `/api/0/users` | List all users | Admin |
|
||||||
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
|
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
|
||||||
|
| `PUT` | `/api/0/users/me` | Update own profile | Authenticated |
|
||||||
|
|
||||||
|
### Notification Channels
|
||||||
|
|
||||||
|
| Method | Path | Description | Role |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| `GET` | `/api/0/notification_channel_types` | Channel type schemas | Authenticated |
|
||||||
|
| `GET` | `/api/0/notification_channels` | List visible channels | Authenticated |
|
||||||
|
| `POST` | `/api/0/notification_channels` | Create a channel | Authenticated |
|
||||||
|
| `PUT` | `/api/0/notification_channels/{name}` | Update a channel | Owner or Admin |
|
||||||
|
| `DELETE` | `/api/0/notification_channels/{name}` | Delete a channel | Owner or Admin |
|
||||||
|
|
||||||
### Host Management
|
### Host Management
|
||||||
|
|
||||||
@@ -203,6 +214,101 @@ Changes take effect immediately but are not written back to the config file. Upd
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notification Channel Endpoints
|
||||||
|
|
||||||
|
Channels are visible to all users by default. Channels marked `private: true` are only visible to their owner. Admins see all channels.
|
||||||
|
|
||||||
|
#### GET /api/0/notification_channel_types
|
||||||
|
Return the schema for every supported notifier type. Used by the web UI to dynamically render the channel creation form.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pushover": {
|
||||||
|
"label": "Pushover",
|
||||||
|
"fields": [
|
||||||
|
{"key": "token", "label": "App token", "type": "secret", "required": true},
|
||||||
|
{"key": "user", "label": "User key", "type": "secret", "required": true},
|
||||||
|
{"key": "sound", "label": "Sound", "type": "text", "required": false}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"email": { "label": "E-mail", "fields": [ ... ] },
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/0/notification_channels
|
||||||
|
List channels visible to the current user (public channels + own private channels). Admins receive all channels.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "pushover_ops",
|
||||||
|
"type": "pushover",
|
||||||
|
"type_label": "Pushover",
|
||||||
|
"owner": null,
|
||||||
|
"private": false,
|
||||||
|
"min_level": "WARNING",
|
||||||
|
"fields": [
|
||||||
|
{"key": "token", "label": "App token", "value": "•••", "sensitive": true},
|
||||||
|
{"key": "user", "label": "User key", "value": "•••", "sensitive": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sensitive fields (`type: "secret"`) are always returned as `"•••"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/0/notification_channels
|
||||||
|
Create a new channel. The creating user becomes the channel's `owner`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my_pushover",
|
||||||
|
"type": "pushover",
|
||||||
|
"token": "app-token",
|
||||||
|
"user": "user-key",
|
||||||
|
"min_level": "WARNING",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{"ok": true, "name": "my_pushover"}`
|
||||||
|
|
||||||
|
**Status codes:** `200 OK`, `400` (missing required field or unknown type), `409` (name already exists)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/0/notification_channels/{name}
|
||||||
|
Update an existing channel. Only the channel owner or an admin may update it.
|
||||||
|
|
||||||
|
Secret fields sent as `"•••"` are preserved from the existing config (same pattern as OAuth secrets in the admin config editor).
|
||||||
|
|
||||||
|
**Request body:** same shape as POST, `name` ignored (taken from URL).
|
||||||
|
|
||||||
|
**Response:** `{"ok": true}`
|
||||||
|
|
||||||
|
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE /api/0/notification_channels/{name}
|
||||||
|
Delete a channel. Only the channel owner or an admin may delete it.
|
||||||
|
|
||||||
|
**Response:** `{"ok": true}`
|
||||||
|
|
||||||
|
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Alert Endpoints
|
### Alert Endpoints
|
||||||
|
|
||||||
#### GET /api/0/hosts/{hostname}/alerts
|
#### GET /api/0/hosts/{hostname}/alerts
|
||||||
|
|||||||
+37
-7
@@ -30,9 +30,17 @@ Set `base_url` so notification links point to your hbd instance:
|
|||||||
base_url: https://hbd.example.com
|
base_url: https://hbd.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Global channel definitions
|
### Channel definitions
|
||||||
|
|
||||||
Define channels once; reference them by name from user configs:
|
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
|
||||||
|
| `private` | `false` | When `true`, only the owner can see and select this channel. |
|
||||||
|
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
|
||||||
|
|
||||||
|
**Admin-created channels** (set in the config file or via the admin settings UI) are public by default — all users can select them:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -41,7 +49,7 @@ notification_channels:
|
|||||||
type: pushover
|
type: pushover
|
||||||
token: your-app-token
|
token: your-app-token
|
||||||
user: your-user-key
|
user: your-user-key
|
||||||
min_level: WARNING # optional, default: WARNING
|
min_level: WARNING
|
||||||
|
|
||||||
email_ops:
|
email_ops:
|
||||||
type: email
|
type: email
|
||||||
@@ -58,14 +66,14 @@ notification_channels:
|
|||||||
homeserver: https://matrix.example.org
|
homeserver: https://matrix.example.org
|
||||||
access_token: syt_xxx
|
access_token: syt_xxx
|
||||||
room_id: "!abc:matrix.example.org"
|
room_id: "!abc:matrix.example.org"
|
||||||
min_level: CRITICAL # only send critical alerts to this room
|
min_level: CRITICAL
|
||||||
|
|
||||||
sms_oncall:
|
sms_oncall:
|
||||||
type: sms_voipms
|
type: sms_voipms
|
||||||
api_user: me@example.com
|
api_user: me@example.com
|
||||||
api_password: secret
|
api_password: secret
|
||||||
did: "5551234567" # your voip.ms DID number
|
did: "5551234567"
|
||||||
dst: "5559876543" # destination number
|
dst: "5559876543"
|
||||||
min_level: CRITICAL
|
min_level: CRITICAL
|
||||||
|
|
||||||
signal_ops:
|
signal_ops:
|
||||||
@@ -82,9 +90,30 @@ notification_channels:
|
|||||||
username: heartbeat-bot
|
username: heartbeat-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**User-created channels** are written by authenticated users through the API or their profile page. They carry an `owner` field and optionally `private: true`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notification_channels:
|
||||||
|
|
||||||
|
alice_personal:
|
||||||
|
type: pushover
|
||||||
|
token: personal-token
|
||||||
|
user: personal-key
|
||||||
|
owner: alice # created by alice
|
||||||
|
private: true # only alice can see this channel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel visibility
|
||||||
|
|
||||||
|
| Channel | Who can see / select it |
|
||||||
|
|---|---|
|
||||||
|
| No `private` field (or `private: false`) | All users |
|
||||||
|
| `private: true` | Only the `owner` |
|
||||||
|
| Any channel | Admins always see everything |
|
||||||
|
|
||||||
### Users with notification channels
|
### Users with notification channels
|
||||||
|
|
||||||
Each user lists which global channels they receive notifications on:
|
Each user lists which channels they receive notifications on. Users can manage their own selection from the profile page:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
users:
|
users:
|
||||||
@@ -270,6 +299,7 @@ Called once at startup from `main.py`. Pass the running asyncio event loop so Ma
|
|||||||
- Check that the host has an `owner` or `managers` set
|
- Check that the host has an `owner` or `managers` set
|
||||||
- Check that users have `notification_channels` listed
|
- Check that users have `notification_channels` listed
|
||||||
- Check that the channel names in user config match keys under `notification_channels:`
|
- Check that the channel names in user config match keys under `notification_channels:`
|
||||||
|
- If a user can't select a channel, check whether it is `private: true` and owned by someone else
|
||||||
|
|
||||||
**min_level filtering too aggressive:**
|
**min_level filtering too aggressive:**
|
||||||
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
||||||
|
|||||||
+27
-1
@@ -36,7 +36,7 @@ users:
|
|||||||
bob:
|
bob:
|
||||||
full_name: Bob Smith
|
full_name: Bob Smith
|
||||||
password: pbkdf2:sha256:...
|
password: pbkdf2:sha256:...
|
||||||
notification_channels: [pushover_standard]
|
notification_channels: [pushover_standard] # channels bob has selected
|
||||||
|
|
||||||
carol:
|
carol:
|
||||||
full_name: Carol Jones
|
full_name: Carol Jones
|
||||||
@@ -188,6 +188,32 @@ Return the currently authenticated user's profile.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### PUT /api/0/users/me
|
||||||
|
Update the current user's profile. All fields are optional — send only what you want to change.
|
||||||
|
|
||||||
|
**Update display name and avatar:**
|
||||||
|
```json
|
||||||
|
{ "full_name": "Carol Jones", "avatar": "/avatars/carol.png" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change notification channel selection:**
|
||||||
|
```json
|
||||||
|
{ "notification_channels": ["pushover_ops", "email_ops"] }
|
||||||
|
```
|
||||||
|
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
|
||||||
|
|
||||||
|
**Change password:**
|
||||||
|
```json
|
||||||
|
{ "password": { "current": "oldpass", "new": "newpass" } }
|
||||||
|
```
|
||||||
|
Requires the correct current password. New password is hashed before storage.
|
||||||
|
|
||||||
|
**Response:** `{"ok": true}`
|
||||||
|
|
||||||
|
**Status codes:** `200 OK`, `400` (missing/invalid field), `401` (unauthenticated), `403` (wrong current password)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Host Access
|
### Host Access
|
||||||
|
|
||||||
#### GET /api/0/hosts/{hostname}/access
|
#### GET /api/0/hosts/{hostname}/access
|
||||||
|
|||||||
@@ -1,602 +0,0 @@
|
|||||||
# Plugin Error Checking Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Improve plugin error checking in hbc, especially for nagios_runner, and fix logger messages silently discarded in daemon mode.
|
|
||||||
|
|
||||||
**Architecture:** Three focused changes across three files: (1) `hbd/client/plugin.py` gains a `skip_reason` attribute on Plugin and updated PluginLoader messaging; (2) `hbd/client/plugins/nagios_runner.py` gains async subprocess execution, stderr capture, signal-killed process handling, and init-time command path validation; (3) `hbd/client/main.py` gains proper post-fork logging reconfiguration to syslog.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.11+, asyncio, `logging.handlers.SysLogHandler`, pytest
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path | What changes |
|
|
||||||
|---|---|---|
|
|
||||||
| Modify | `hbd/client/plugin.py` | `Plugin.__init__` gains `skip_reason`; `PluginLoader` checks it |
|
|
||||||
| Modify | `hbd/client/plugins/nagios_runner.py` | async subprocess, stderr, signal codes, init validation, `skip_reason` |
|
|
||||||
| Modify | `hbd/client/main.py` | `_reconfigure_logging_for_daemon()` helper; remove redundant syslog calls |
|
|
||||||
| Create | `tests/test_plugin.py` | PluginLoader messaging tests |
|
|
||||||
| Create | `tests/test_nagios_runner.py` | NagiosRunnerPlugin behaviour tests |
|
|
||||||
|
|
||||||
Run tests throughout with:
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Plugin.skip_reason + PluginLoader messaging
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugin.py:40-48` (Plugin.__init__)
|
|
||||||
- Modify: `hbd/client/plugin.py:369-381` (PluginLoader.load_from_directory)
|
|
||||||
- Create: `tests/test_plugin.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Create `tests/test_plugin.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from hbd.client.plugin import Plugin, PluginLoader, PluginRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_skip_reason_defaults_none(tmp_path):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class MinimalPlugin(MonitorPlugin):
|
|
||||||
name = "minimal"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "minimal.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
plugin = registry.get("minimal")
|
|
||||||
assert plugin is not None
|
|
||||||
assert plugin.skip_reason is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_loader_logs_info_when_skip_reason_set(tmp_path, caplog):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class SkippablePlugin(MonitorPlugin):
|
|
||||||
name = "skippable"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
self.skip_reason = "not configured in yaml"
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "skippable.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="plugin.loader"):
|
|
||||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
assert any("skipped: not configured in yaml" in r.message for r in caplog.records)
|
|
||||||
assert not any("failed initialization" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_loader_logs_warning_when_no_skip_reason(tmp_path, caplog):
|
|
||||||
plugin_code = textwrap.dedent("""
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
|
|
||||||
class FailPlugin(MonitorPlugin):
|
|
||||||
name = "fail"
|
|
||||||
version = "1.0.0"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _collect_metrics(self):
|
|
||||||
return {}
|
|
||||||
""")
|
|
||||||
(tmp_path / "fail_plugin.py").write_text(plugin_code)
|
|
||||||
registry = PluginRegistry()
|
|
||||||
loader = PluginLoader(registry)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.loader"):
|
|
||||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
assert any("failed initialization" in r.message for r in caplog.records)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py -v
|
|
||||||
```
|
|
||||||
Expected: `test_plugin_skip_reason_defaults_none` FAILS (attribute missing), others may error.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `skip_reason` to `Plugin.__init__`**
|
|
||||||
|
|
||||||
In `hbd/client/plugin.py`, in `Plugin.__init__` (around line 46), add one line:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
|
||||||
self._initialized = False
|
|
||||||
self.skip_reason: Optional[str] = None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update PluginLoader messaging**
|
|
||||||
|
|
||||||
In `hbd/client/plugin.py`, replace the `if not initialized:` block (around line 372):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not initialized:
|
|
||||||
if plugin.skip_reason:
|
|
||||||
self.logger.info(
|
|
||||||
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Plugin {plugin.name} failed initialization, skipping"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests to verify they pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py -v
|
|
||||||
```
|
|
||||||
Expected: all 3 tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugin.py tests/test_plugin.py
|
|
||||||
git commit -m "feat: add skip_reason to Plugin; improve PluginLoader init messaging"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: NagiosRunnerPlugin — skip_reason when no commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py:88-105` (initialize)
|
|
||||||
- Modify: `tests/test_nagios_runner.py` (create)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
Create `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from hbd.client.plugins.nagios_runner import (
|
|
||||||
NagiosRunnerPlugin,
|
|
||||||
NAGIOS_OK,
|
|
||||||
NAGIOS_WARNING,
|
|
||||||
NAGIOS_CRITICAL,
|
|
||||||
NAGIOS_UNKNOWN,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_commands_sets_skip_reason():
|
|
||||||
plugin = NagiosRunnerPlugin(config={"commands": []})
|
|
||||||
result = asyncio.run(plugin.initialize())
|
|
||||||
assert result is False
|
|
||||||
assert plugin.skip_reason is not None
|
|
||||||
assert "nagios_runner.commands" in plugin.skip_reason
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
|
||||||
```
|
|
||||||
Expected: FAIL — `plugin.skip_reason` is `None`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Set skip_reason in NagiosRunnerPlugin.initialize()**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, replace the early-return block in `initialize()` (around line 96):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not self.commands:
|
|
||||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
|
||||||
self.logger.info("No Nagios commands configured")
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
|
||||||
```
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: set skip_reason on nagios_runner when no commands configured"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: NagiosRunnerPlugin — async subprocess, stderr capture, negative return codes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py` (imports + `_run_nagios_plugin`)
|
|
||||||
- Modify: `tests/test_nagios_runner.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_stderr_used_when_stdout_empty(tmp_path):
|
|
||||||
script = tmp_path / "check_err.sh"
|
|
||||||
script.write_text("#!/bin/sh\necho 'error from stderr' >&2\nexit 2\n")
|
|
||||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert "error from stderr" in data["t_output"]
|
|
||||||
assert data["t_status_code"] == NAGIOS_CRITICAL
|
|
||||||
|
|
||||||
|
|
||||||
def test_stderr_appended_when_both_present(tmp_path):
|
|
||||||
script = tmp_path / "check_both.sh"
|
|
||||||
script.write_text("#!/bin/sh\necho 'OK - all good'\necho 'extra detail' >&2\nexit 0\n")
|
|
||||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert "OK - all good" in data["t_output"]
|
|
||||||
assert "extra detail" in data["t_output"]
|
|
||||||
assert data["t_status_code"] == NAGIOS_OK
|
|
||||||
|
|
||||||
|
|
||||||
def test_negative_returncode_maps_to_unknown():
|
|
||||||
# kill -9 $$ kills the shell itself; asyncio sees returncode -9
|
|
||||||
config = {"commands": [{"name": "t", "command": "kill -9 $$"}], "timeout": 5}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
data = asyncio.run(plugin._collect_metrics())
|
|
||||||
|
|
||||||
assert data["t_status_code"] == NAGIOS_UNKNOWN
|
|
||||||
assert "signal" in data["t_output"].lower()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_stderr_used_when_stdout_empty \
|
|
||||||
tests/test_nagios_runner.py::test_stderr_appended_when_both_present \
|
|
||||||
tests/test_nagios_runner.py::test_negative_returncode_maps_to_unknown -v
|
|
||||||
```
|
|
||||||
Expected: all FAIL — current implementation ignores stderr and doesn't handle negative codes.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update imports in nagios_runner.py**
|
|
||||||
|
|
||||||
Replace the import block at the top of `hbd/client/plugins/nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from hbd.client.plugin import MonitorPlugin
|
|
||||||
```
|
|
||||||
|
|
||||||
(Remove `import subprocess`; add `import asyncio` and `import os`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Upgrade collection log level from DEBUG to INFO**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, in `_collect_metrics()`, change the debug log (around line 144) so results are visible at INFO level:
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.logger.info(
|
|
||||||
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Replace `_run_nagios_plugin` with async implementation**
|
|
||||||
|
|
||||||
Replace the entire `_run_nagios_plugin` method in `hbd/client/plugins/nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _run_nagios_plugin(
|
|
||||||
self,
|
|
||||||
command: str
|
|
||||||
) -> Tuple[int, str, Dict[str, Any]]:
|
|
||||||
"""Execute a Nagios plugin and parse its output."""
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
||||||
proc.communicate(), timeout=self.timeout
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
await proc.communicate()
|
|
||||||
self.logger.error(f"Command timed out: {command}")
|
|
||||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
|
||||||
|
|
||||||
status_code = proc.returncode
|
|
||||||
|
|
||||||
if status_code < 0:
|
|
||||||
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
|
|
||||||
|
|
||||||
if status_code > 3:
|
|
||||||
status_code = NAGIOS_UNKNOWN
|
|
||||||
|
|
||||||
stdout = stdout_bytes.decode(errors="replace").strip()
|
|
||||||
stderr = stderr_bytes.decode(errors="replace").strip()
|
|
||||||
|
|
||||||
# Parse perfdata from stdout before mixing in stderr
|
|
||||||
perfdata = self._parse_perfdata(stdout)
|
|
||||||
|
|
||||||
# Build status message
|
|
||||||
status_part = stdout.split('|')[0].strip() if '|' in stdout else stdout
|
|
||||||
|
|
||||||
if not stdout and stderr:
|
|
||||||
output_msg = stderr
|
|
||||||
elif stdout and stderr:
|
|
||||||
output_msg = f"{status_part} [stderr: {stderr}]"
|
|
||||||
else:
|
|
||||||
output_msg = status_part
|
|
||||||
|
|
||||||
return status_code, output_msg, perfdata
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error executing command: {e}")
|
|
||||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also remove the now-unused `self.shell` line from `__init__` (the `shell` config key is no longer used since `create_subprocess_shell` always uses a shell):
|
|
||||||
|
|
||||||
In `NagiosRunnerPlugin.__init__`, remove:
|
|
||||||
```python
|
|
||||||
self.shell: bool = config.get("shell", True) if config else True
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run tests to verify they pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests PASS including the 3 new ones.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: async subprocess in nagios_runner with stderr capture and signal handling"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: NagiosRunnerPlugin — command path validation at init
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/plugins/nagios_runner.py` (initialize)
|
|
||||||
- Modify: `tests/test_nagios_runner.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_nagios_runner.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_absolute_path_not_found_warns(caplog):
|
|
||||||
fake_cmd = "/nonexistent_hbc_test_path/check_something"
|
|
||||||
config = {"commands": [{"name": "t", "command": fake_cmd}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert any("not found" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_path_not_executable_warns(caplog, tmp_path):
|
|
||||||
non_exec = tmp_path / "check_test"
|
|
||||||
non_exec.write_text("#!/bin/sh\necho OK\n")
|
|
||||||
non_exec.chmod(0o644) # readable but not executable
|
|
||||||
|
|
||||||
config = {"commands": [{"name": "t", "command": str(non_exec)}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert any("not executable" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_path_not_checked(caplog):
|
|
||||||
# Relative paths (resolved via PATH) must not generate warnings
|
|
||||||
config = {"commands": [{"name": "t", "command": "echo OK"}]}
|
|
||||||
plugin = NagiosRunnerPlugin(config=config)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
|
||||||
asyncio.run(plugin.initialize())
|
|
||||||
|
|
||||||
assert not any(
|
|
||||||
"not found" in r.message or "not executable" in r.message
|
|
||||||
for r in caplog.records
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_nagios_runner.py::test_absolute_path_not_found_warns \
|
|
||||||
tests/test_nagios_runner.py::test_absolute_path_not_executable_warns \
|
|
||||||
tests/test_nagios_runner.py::test_relative_path_not_checked -v
|
|
||||||
```
|
|
||||||
Expected: `test_absolute_path_not_found_warns` and `test_absolute_path_not_executable_warns` FAIL (no warnings logged); `test_relative_path_not_checked` may pass.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add command path validation to `initialize()`**
|
|
||||||
|
|
||||||
In `hbd/client/plugins/nagios_runner.py`, extend `initialize()` by adding validation after the existing "log each command" loop (after line 103, before `return True`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Validate absolute command paths early
|
|
||||||
for cmd_config in self.commands:
|
|
||||||
name = cmd_config.get("name", "unnamed")
|
|
||||||
command = cmd_config.get("command", "")
|
|
||||||
if not command:
|
|
||||||
continue
|
|
||||||
exe = command.split()[0]
|
|
||||||
if os.path.isabs(exe):
|
|
||||||
if not os.path.isfile(exe):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Command '{name}': executable not found: {exe}"
|
|
||||||
)
|
|
||||||
elif not os.access(exe, os.X_OK):
|
|
||||||
self.logger.warning(
|
|
||||||
f"Command '{name}': executable not executable: {exe}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run full test suite to verify all pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
|
||||||
git commit -m "feat: validate absolute command paths at nagios_runner init"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Daemon mode logging — route to syslog after fork
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/client/main.py` (new helper + updated daemon block)
|
|
||||||
|
|
||||||
No automated test for daemonization itself (fork behaviour is hard to unit-test). Manual verification steps are provided below.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `_reconfigure_logging_for_daemon` helper**
|
|
||||||
|
|
||||||
In `hbd/client/main.py`, add this function just before `def build_parser()` (around line 589):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _reconfigure_logging_for_daemon(log_level: int) -> None:
|
|
||||||
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
|
|
||||||
from logging.handlers import SysLogHandler
|
|
||||||
|
|
||||||
root = logging.getLogger()
|
|
||||||
for handler in root.handlers[:]:
|
|
||||||
root.removeHandler(handler)
|
|
||||||
handler.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
syslog_handler = SysLogHandler(
|
|
||||||
address="/dev/log",
|
|
||||||
facility=SysLogHandler.LOG_DAEMON,
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
syslog_handler = SysLogHandler(
|
|
||||||
address=("localhost", 514),
|
|
||||||
facility=SysLogHandler.LOG_DAEMON,
|
|
||||||
)
|
|
||||||
# Attach the fallback first so the warning reaches syslog
|
|
||||||
syslog_handler.setFormatter(
|
|
||||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
|
||||||
)
|
|
||||||
root.addHandler(syslog_handler)
|
|
||||||
root.setLevel(log_level)
|
|
||||||
logging.warning("/dev/log not found, using syslog UDP localhost:514")
|
|
||||||
return
|
|
||||||
|
|
||||||
syslog_handler.setFormatter(
|
|
||||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
|
||||||
)
|
|
||||||
root.addHandler(syslog_handler)
|
|
||||||
root.setLevel(log_level)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the daemon block in `main()`**
|
|
||||||
|
|
||||||
In `hbd/client/main.py`, replace the entire `if args.daemon:` block (lines 664–675):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if args.daemon:
|
|
||||||
print("Daemonizing...")
|
|
||||||
daemonize()
|
|
||||||
_reconfigure_logging_for_daemon(log_level)
|
|
||||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
This removes the `import syslog`, `syslog.openlog()`, and `syslog.syslog()` calls (now handled by the logging system) and removes the no-op second `logging.basicConfig()` call.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run existing test suite to confirm no regressions**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
|
||||||
```
|
|
||||||
Expected: all tests still PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual smoke test — verify syslog output in daemon mode**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In one terminal, tail syslog
|
|
||||||
sudo journalctl -f -t hbc
|
|
||||||
|
|
||||||
# In another terminal, start hbc in daemon mode (replace HOST with a real or dummy host)
|
|
||||||
python -m hbd.client.main -d -v localhost
|
|
||||||
|
|
||||||
# Expected in journalctl output:
|
|
||||||
# hbc[<pid>]: hbc.main INFO: Starting hbc for <hostname> -> ['localhost']
|
|
||||||
# hbc[<pid>]: hbc.main INFO: hbc starting, sending heartbeat to localhost
|
|
||||||
# hbc[<pid>]: plugin.loader INFO: ...
|
|
||||||
|
|
||||||
# Stop the daemon
|
|
||||||
pkill -f "hbd.client.main"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/client/main.py
|
|
||||||
git commit -m "fix: reconfigure logging to syslog after daemonize() instead of no-op basicConfig"
|
|
||||||
```
|
|
||||||
@@ -1,781 +0,0 @@
|
|||||||
# Gitea OAuth2 Authentication Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add Gitea as an OAuth2 login provider that coexists with password auth, auto-provisioning new users on first login.
|
|
||||||
|
|
||||||
**Architecture:** A new `oauth.py` module owns all Gitea-specific logic (CSRF state, URL building, token exchange, user-info fetch). `users.py` gains one function to upsert an OAuth-sourced user. `http.py` gets two new route handlers and a small login-page change. No new dependencies — `aiohttp.ClientSession` is already used in the codebase.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, aiohttp 3.x, pytest, pytest-asyncio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path | Responsibility |
|
|
||||||
|--------|------|----------------|
|
|
||||||
| Modify | `hbd/server/config.py` | Add `"oauth": {}` default |
|
|
||||||
| Create | `hbd/server/oauth.py` | CSRF state, URL builder, token exchange, user-info fetch |
|
|
||||||
| Modify | `hbd/server/users.py` | Add `provision_oauth_user()` |
|
|
||||||
| Modify | `hbd/server/http.py` | Import oauth, two new routes, login page button |
|
|
||||||
| Create | `tests/test_oauth.py` | All new unit tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Add config default and `is_enabled()`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/config.py:34` (after the `"users"` line)
|
|
||||||
- Create: `hbd/server/oauth.py`
|
|
||||||
- Create: `tests/test_oauth.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from hbd.server import oauth
|
|
||||||
|
|
||||||
|
|
||||||
CFG_OFF = {}
|
|
||||||
CFG_ON = {
|
|
||||||
"oauth": {
|
|
||||||
"gitea": {
|
|
||||||
"url": "https://git.example.com",
|
|
||||||
"client_id": "cid",
|
|
||||||
"client_secret": "csec",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_when_all_keys_present():
|
|
||||||
assert oauth.is_enabled(CFG_ON) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_no_oauth_key():
|
|
||||||
assert oauth.is_enabled(CFG_OFF) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_partial_config():
|
|
||||||
assert oauth.is_enabled(CFG_PARTIAL) is False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `ModuleNotFoundError: No module named 'hbd.server.oauth'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add config default**
|
|
||||||
|
|
||||||
In `hbd/server/config.py`, add after the `"default_owner"` line (currently line 35):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# OAuth2 providers
|
|
||||||
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `hbd/server/oauth.py` with `is_enabled`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Gitea OAuth2 support.
|
|
||||||
|
|
||||||
Config shape (in ~/.hb.yaml):
|
|
||||||
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://git.example.com
|
|
||||||
client_id: <client-id>
|
|
||||||
client_secret: <client-secret>
|
|
||||||
|
|
||||||
Register a Gitea OAuth2 application at:
|
|
||||||
Gitea → Settings → Applications → OAuth2
|
|
||||||
Set the redirect URI to:
|
|
||||||
https://<hbd-host>/login/oauth/gitea/callback
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_TTL = 600 # 10 minutes
|
|
||||||
|
|
||||||
# state_token -> expiry timestamp
|
|
||||||
_states: dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthError(Exception):
|
|
||||||
"""Raised when the OAuth2 flow fails for any reason."""
|
|
||||||
|
|
||||||
|
|
||||||
def _gitea_cfg(config: dict) -> dict:
|
|
||||||
"""Return the gitea sub-dict or {} if absent/incomplete."""
|
|
||||||
return config.get("oauth", {}).get("gitea", {})
|
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(config: dict) -> bool:
|
|
||||||
"""Return True when all three required Gitea OAuth keys are present."""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 3 passed
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/config.py hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add oauth module skeleton and is_enabled()"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: CSRF state management
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/oauth.py` (add `make_state`, `validate_state`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add state tests)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import time as time_mod
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_state_returns_unique_tokens():
|
|
||||||
s1 = oauth.make_state()
|
|
||||||
s2 = oauth.make_state()
|
|
||||||
assert s1 != s2
|
|
||||||
assert len(s1) == 64 # 32 bytes hex
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_valid():
|
|
||||||
state = oauth.make_state()
|
|
||||||
assert oauth.validate_state(state) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_consumed_on_use():
|
|
||||||
state = oauth.make_state()
|
|
||||||
oauth.validate_state(state)
|
|
||||||
assert oauth.validate_state(state) is False # replay rejected
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_unknown():
|
|
||||||
assert oauth.validate_state("notastate") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_state_expired(monkeypatch):
|
|
||||||
state = oauth.make_state()
|
|
||||||
# Wind expiry into the past
|
|
||||||
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1)
|
|
||||||
assert oauth.validate_state(state) is False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "state"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'make_state'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement state functions**
|
|
||||||
|
|
||||||
Add to `hbd/server/oauth.py` after the `_states` dict definition:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def make_state() -> str:
|
|
||||||
"""Generate a CSRF state token, store it with TTL, and return it."""
|
|
||||||
_purge_states()
|
|
||||||
token = secrets.token_hex(32)
|
|
||||||
_states[token] = time.time() + STATE_TTL
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def validate_state(state: str) -> bool:
|
|
||||||
"""Return True if *state* is known and unexpired; always removes it."""
|
|
||||||
expiry = _states.pop(state, None)
|
|
||||||
if expiry is None:
|
|
||||||
return False
|
|
||||||
return time.time() < expiry
|
|
||||||
|
|
||||||
|
|
||||||
def _purge_states() -> None:
|
|
||||||
now = time.time()
|
|
||||||
expired = [k for k, exp in list(_states.items()) if exp < now]
|
|
||||||
for k in expired:
|
|
||||||
del _states[k]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 8 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add OAuth2 CSRF state management"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: `provision_oauth_user` in users.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/users.py` (add `provision_oauth_user`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add provisioning tests)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from hbd.server import users as users_mod
|
|
||||||
from hbd.server.users import User
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_users(entries=None):
|
|
||||||
users_mod.users = entries or {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_new():
|
|
||||||
_reset_users()
|
|
||||||
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
|
|
||||||
assert user.username == "gituser"
|
|
||||||
assert user.full_name == "Git User"
|
|
||||||
assert user.avatar == "https://example.com/avatar.png"
|
|
||||||
assert user.admin is False
|
|
||||||
assert user.password_hash == ""
|
|
||||||
assert "gituser" in users_mod.users
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_no_password_login():
|
|
||||||
_reset_users()
|
|
||||||
user = users_mod.provision_oauth_user("gituser", "Git User", "")
|
|
||||||
assert user.check_password("anything") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_existing_updates_profile():
|
|
||||||
existing = User(
|
|
||||||
username="alice",
|
|
||||||
full_name="Old Name",
|
|
||||||
avatar="old.png",
|
|
||||||
password_hash="pbkdf2:sha256:1:salt:abc",
|
|
||||||
admin=True,
|
|
||||||
notification_channels=["chan1"],
|
|
||||||
)
|
|
||||||
_reset_users({"alice": existing})
|
|
||||||
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
|
|
||||||
assert user.full_name == "New Name"
|
|
||||||
assert user.avatar == "new.png"
|
|
||||||
# Preserved
|
|
||||||
assert user.admin is True
|
|
||||||
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
|
|
||||||
assert user.notification_channels == ["chan1"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_provision_oauth_user_does_not_overwrite_with_empty():
|
|
||||||
existing = User(username="bob", full_name="Bob", avatar="bob.png")
|
|
||||||
_reset_users({"bob": existing})
|
|
||||||
user = users_mod.provision_oauth_user("bob", "", "")
|
|
||||||
assert user.full_name == "Bob"
|
|
||||||
assert user.avatar == "bob.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "provision"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.users' has no attribute 'provision_oauth_user'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `provision_oauth_user`**
|
|
||||||
|
|
||||||
Add to `hbd/server/users.py` after the `authenticate()` function (after line 187):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
|
|
||||||
"""Create or update a user sourced from an OAuth2 provider.
|
|
||||||
|
|
||||||
New users are inserted with no password_hash — they can only authenticate
|
|
||||||
via OAuth. Existing users (e.g. defined in config with a password) have
|
|
||||||
their display name and avatar refreshed; all other attributes are preserved.
|
|
||||||
"""
|
|
||||||
user = users.get(username)
|
|
||||||
if user is None:
|
|
||||||
user = User(username=username, full_name=full_name, avatar=avatar)
|
|
||||||
users[username] = user
|
|
||||||
logger.info("Provisioned OAuth user %r", username)
|
|
||||||
else:
|
|
||||||
if full_name:
|
|
||||||
user.full_name = full_name
|
|
||||||
if avatar:
|
|
||||||
user.avatar = avatar
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 12 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/users.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add provision_oauth_user() to users module"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: URL builder, token exchange, and user-info fetch
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/oauth.py` (add `authorization_url`, `exchange_code`, `fetch_user`)
|
|
||||||
- Modify: `tests/test_oauth.py` (add async tests with mocked HTTP)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `tests/test_oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
|
|
||||||
|
|
||||||
def test_authorization_url_shape():
|
|
||||||
state = "teststate"
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
|
|
||||||
parsed = urlparse(url)
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
assert parsed.scheme == "https"
|
|
||||||
assert parsed.netloc == "git.example.com"
|
|
||||||
assert parsed.path == "/login/oauth/authorize"
|
|
||||||
assert qs["client_id"] == ["cid"]
|
|
||||||
assert qs["state"] == ["teststate"]
|
|
||||||
assert qs["redirect_uri"] == [redirect_uri]
|
|
||||||
assert qs["scope"] == ["user:email"]
|
|
||||||
assert qs["response_type"] == ["code"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_returns_token():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
|
|
||||||
assert token == "tok123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_raises_on_error_status():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 401
|
|
||||||
mock_response.text = AsyncMock(return_value="unauthorized")
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
with pytest.raises(oauth.OAuthError):
|
|
||||||
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_user_returns_profile():
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
profile = await oauth.fetch_user(CFG_ON, "tok123")
|
|
||||||
assert profile == {
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to confirm failure**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v -k "url or exchange or fetch"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'authorization_url'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the three functions**
|
|
||||||
|
|
||||||
Add to `hbd/server/oauth.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
|
|
||||||
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
|
|
||||||
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
params = urllib.parse.urlencode({
|
|
||||||
"client_id": g["client_id"],
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": "user:email",
|
|
||||||
"state": state,
|
|
||||||
})
|
|
||||||
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
|
|
||||||
|
|
||||||
|
|
||||||
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
|
||||||
"""Exchange an authorization *code* for a Gitea access token.
|
|
||||||
|
|
||||||
Returns the access token string. Raises OAuthError on any failure.
|
|
||||||
"""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
|
|
||||||
payload = {
|
|
||||||
"client_id": g["client_id"],
|
|
||||||
"client_secret": g["client_secret"],
|
|
||||||
"code": code,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
}
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
|
||||||
data = await resp.json()
|
|
||||||
except aiohttp.ClientError as exc:
|
|
||||||
raise OAuthError(f"Token exchange network error: {exc}") from exc
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise OAuthError(f"No access_token in response: {data}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_user(config: dict, token: str) -> dict:
|
|
||||||
"""Fetch the authenticated user's profile from Gitea.
|
|
||||||
|
|
||||||
Returns a dict with keys: login, full_name, avatar_url.
|
|
||||||
Raises OAuthError on any failure.
|
|
||||||
"""
|
|
||||||
g = _gitea_cfg(config)
|
|
||||||
url = f"{g['url'].rstrip('/')}/api/v1/user"
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
text = await resp.text()
|
|
||||||
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
|
||||||
data = await resp.json()
|
|
||||||
except aiohttp.ClientError as exc:
|
|
||||||
raise OAuthError(f"User fetch network error: {exc}") from exc
|
|
||||||
return {
|
|
||||||
"login": data.get("login", ""),
|
|
||||||
"full_name": data.get("full_name", ""),
|
|
||||||
"avatar_url": data.get("avatar_url", ""),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add `import urllib.parse` at the top of `oauth.py` (alongside the existing imports).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run to confirm tests pass**
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest tests/test_oauth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 17 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/oauth.py tests/test_oauth.py
|
|
||||||
git commit -m "feat: add authorization_url, exchange_code, fetch_user to oauth module"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: HTTP routes — redirect and callback
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/http.py`
|
|
||||||
|
|
||||||
`http.py` defines all handlers inside `async def start(...)`. The two new handlers go in the same block, just before the `app = web.Application()` line (~line 900). The import goes at the top of the file.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the import**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add after the existing local imports (after `from . import users as users_mod`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from . import oauth as oauth_mod
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the two route handlers**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add the two handlers immediately before the `app = web.Application()` line:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def oauth_gitea_redirect(request):
|
|
||||||
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
|
|
||||||
if not oauth_mod.is_enabled(config):
|
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
|
||||||
state = oauth_mod.make_state()
|
|
||||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
|
||||||
raise web.HTTPFound(oauth_mod.authorization_url(config, state, redirect_uri))
|
|
||||||
|
|
||||||
async def oauth_gitea_callback(request):
|
|
||||||
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
|
|
||||||
if not oauth_mod.is_enabled(config):
|
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
|
||||||
code = request.rel_url.query.get("code", "")
|
|
||||||
state = request.rel_url.query.get("state", "")
|
|
||||||
if not code or not state:
|
|
||||||
return web.Response(status=400, text="Missing code or state")
|
|
||||||
if not oauth_mod.validate_state(state):
|
|
||||||
raise web.HTTPFound("/login?error=1")
|
|
||||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
|
||||||
try:
|
|
||||||
token = await oauth_mod.exchange_code(config, code, redirect_uri)
|
|
||||||
profile = await oauth_mod.fetch_user(config, token)
|
|
||||||
except oauth_mod.OAuthError as exc:
|
|
||||||
logger.warning("OAuth error: %s", exc)
|
|
||||||
raise web.HTTPFound("/login?error=1")
|
|
||||||
user = users_mod.provision_oauth_user(
|
|
||||||
profile["login"],
|
|
||||||
profile["full_name"],
|
|
||||||
profile["avatar_url"],
|
|
||||||
)
|
|
||||||
session_token = users_mod.create_session(user.username)
|
|
||||||
resp = web.HTTPFound("/")
|
|
||||||
resp.set_cookie(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
session_token,
|
|
||||||
max_age=users_mod.SESSION_TTL,
|
|
||||||
httponly=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
raise resp
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Register the routes**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, add to the route list after the existing auth routes (after `web.post("/api/0/auth/logout", api_logout)`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
web.get("/login/oauth/gitea", oauth_gitea_redirect),
|
|
||||||
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual smoke test**
|
|
||||||
|
|
||||||
Start the server locally with OAuth configured in `~/.hb.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://your-gitea-instance.example.com
|
|
||||||
client_id: your-client-id
|
|
||||||
client_secret: your-client-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:50004/login/oauth/gitea` — confirm you are redirected to Gitea's authorization page.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/http.py
|
|
||||||
git commit -m "feat: add Gitea OAuth2 redirect and callback routes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Login page — "Sign in with Gitea" button
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `hbd/server/http.py` (update `login_page` handler, ~line 625)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace the login page HTML**
|
|
||||||
|
|
||||||
In `hbd/server/http.py`, find the `html = f"""` block inside `login_page` and replace it with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
gitea_button = ""
|
|
||||||
if oauth_mod.is_enabled(config):
|
|
||||||
gitea_url = _gitea_cfg_url(config)
|
|
||||||
gitea_button = f"""
|
|
||||||
<div class="divider">or</div>
|
|
||||||
<a href="/login/oauth/gitea" class="gitea-btn">
|
|
||||||
Sign in with Gitea
|
|
||||||
</a>"""
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Heartbeat — Login</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: sans-serif; background: #f5f5f5; display: flex;
|
|
||||||
justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
|
||||||
.box {{ background: #fff; padding: 2em 2.5em; border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,.15); min-width: 300px; }}
|
|
||||||
h2 {{ margin: 0 0 1.2em; color: #333; font-size: 1.4em; }}
|
|
||||||
label {{ display: block; margin-bottom: .3em; font-size: .9em; color: #555; }}
|
|
||||||
input {{ width: 100%; padding: .5em .7em; border: 1px solid #ccc;
|
|
||||||
border-radius: 4px; font-size: 1em; box-sizing: border-box; }}
|
|
||||||
button {{ margin-top: 1.2em; width: 100%; padding: .6em; background: #0066cc;
|
|
||||||
color: #fff; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }}
|
|
||||||
button:hover {{ background: #0055aa; }}
|
|
||||||
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
|
|
||||||
.field {{ margin-bottom: .9em; }}
|
|
||||||
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
|
||||||
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
|
||||||
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926;
|
|
||||||
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
|
|
||||||
text-decoration: none; box-sizing: border-box; }}
|
|
||||||
.gitea-btn:hover {{ background: #4e7d1e; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="box">
|
|
||||||
<h2>Heartbeat</h2>
|
|
||||||
{'<p class="error">Invalid username, password, or OAuth error.</p>' if error else ''}
|
|
||||||
<form method="post">
|
|
||||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
|
||||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
</form>{gitea_button}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the `_gitea_cfg_url` helper**
|
|
||||||
|
|
||||||
Add this small helper in `hbd/server/http.py` just before the `login_page` handler (around line 600) so the template can read the Gitea display URL without importing internal oauth details:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _gitea_cfg_url(config: dict) -> str:
|
|
||||||
return config.get("oauth", {}).get("gitea", {}).get("url", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the `login_page` handler's `error` logic to show the error when the `?error=1` query param is present (set by the callback on OAuth failure):
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def login_page(request):
|
|
||||||
"""GET /login — show login form; POST /login — process and redirect."""
|
|
||||||
if not users_mod.users_enabled():
|
|
||||||
raise web.HTTPFound("/")
|
|
||||||
|
|
||||||
error = ""
|
|
||||||
if request.method == "POST":
|
|
||||||
form = await request.post()
|
|
||||||
username = form.get("username", "")
|
|
||||||
password = form.get("password", "")
|
|
||||||
user = users_mod.authenticate(username, password)
|
|
||||||
if user:
|
|
||||||
token = users_mod.create_session(username)
|
|
||||||
redirect_to = request.rel_url.query.get("next", "/")
|
|
||||||
resp = web.HTTPFound(redirect_to)
|
|
||||||
resp.set_cookie(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
token,
|
|
||||||
max_age=users_mod.SESSION_TTL,
|
|
||||||
httponly=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
raise resp
|
|
||||||
error = "Invalid username or password."
|
|
||||||
elif request.rel_url.query.get("error"):
|
|
||||||
error = "Sign-in failed. Please try again."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual verification**
|
|
||||||
|
|
||||||
Start the server with OAuth configured. Visit `/login`. Confirm:
|
|
||||||
- The "Sign in with Gitea" button appears (green, below a divider)
|
|
||||||
- Clicking it redirects to Gitea
|
|
||||||
- After authorising on Gitea, you are redirected back and land on `/` with a valid session cookie
|
|
||||||
|
|
||||||
Without OAuth configured, confirm the button does not appear.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add hbd/server/http.py
|
|
||||||
git commit -m "feat: add Sign in with Gitea button to login page"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review Notes
|
|
||||||
|
|
||||||
- All 5 spec requirements covered: coexist ✓, auto-provision ✓, regular user ✓, any Gitea user ✓, config-driven ✓
|
|
||||||
- `exchange_code` signature in Task 4 matches usage in Task 5 (`config, code, redirect_uri`) ✓
|
|
||||||
- `fetch_user` returns `{login, full_name, avatar_url}` — matched in callback handler ✓
|
|
||||||
- `validate_state` removes state on use (replay protection) ✓
|
|
||||||
- `provision_oauth_user` skips empty strings so existing avatar/name aren't erased ✓
|
|
||||||
- `_gitea_cfg_url` is a plain `def`, not `async` — safe to call in template prep ✓
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Plugin Error Checking & Daemon Logging — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-04-25
|
|
||||||
**Scope:** hbc client — daemon mode logging, nagios_runner plugin robustness, PluginLoader messaging
|
|
||||||
**Files affected:** `hbd/client/main.py`, `hbd/client/plugins/nagios_runner.py`, `hbd/client/plugin.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Daemon Mode Logging
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
In `main()`, `logging.basicConfig()` is called before `daemonize()` (establishing a StreamHandler to stderr), then called again after `daemonize()`. The second call is a no-op — Python ignores `basicConfig()` when handlers are already configured. After daemonization, stderr is redirected to `/dev/null`, so all subsequent log output is silently discarded.
|
|
||||||
|
|
||||||
The existing `syslog.openlog()` / `syslog.syslog()` calls (lines 666–668) write a single startup message but do not integrate with the `logging` system, so plugin and connection log messages never reach syslog.
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
After `daemonize()`, explicitly reconfigure the root logger:
|
|
||||||
|
|
||||||
1. Remove all existing handlers (they now write to `/dev/null`).
|
|
||||||
2. Add `logging.handlers.SysLogHandler(address='/dev/log', facility=LOG_DAEMON)`.
|
|
||||||
3. Set formatter: `hbc[%(process)d]: %(name)s %(levelname)s: %(message)s`
|
|
||||||
4. Preserve the `log_level` already determined from `-v`/`-x` CLI flags.
|
|
||||||
|
|
||||||
Remove the redundant `syslog.openlog()` / `syslog.syslog()` calls — the logging system handles routing.
|
|
||||||
|
|
||||||
**Fallback:** If `/dev/log` does not exist (containers, some BSDs), fall back to `SysLogHandler(address=('localhost', 514))`. Log one warning (to stderr, before handlers are replaced) so the operator knows.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Nagios Runner Improvements
|
|
||||||
|
|
||||||
### 2a — Async Subprocess
|
|
||||||
`_run_nagios_plugin()` is declared `async def` but calls `subprocess.run()` synchronously, blocking the event loop for the full command duration.
|
|
||||||
|
|
||||||
**Fix:** Replace with `asyncio.create_subprocess_shell()` + `await proc.communicate()`. Enforce timeout with `asyncio.wait_for(..., timeout=self.timeout)` and catch `asyncio.TimeoutError`.
|
|
||||||
|
|
||||||
### 2b — Stderr Capture
|
|
||||||
Subprocess stderr is currently discarded (`capture_output=True` only captures stdout in the sync call; stderr content is lost).
|
|
||||||
|
|
||||||
**Fix:** Pass `stderr=asyncio.subprocess.PIPE` to `create_subprocess_shell`. After `communicate()`, if stdout is empty but stderr has content, use stderr as the output message. If both have content, append stderr to the output for visibility.
|
|
||||||
|
|
||||||
### 2c — Negative Return Codes
|
|
||||||
A negative `returncode` means the process was killed by a signal (SIGKILL, OOM, etc.). The current code treats these as-is, which may produce unexpected status values.
|
|
||||||
|
|
||||||
**Fix:** If `returncode < 0`, map to `NAGIOS_UNKNOWN` with message `"Process killed by signal {-returncode}"`.
|
|
||||||
|
|
||||||
### 2d — Command Path Validation at Init
|
|
||||||
`initialize()` currently only checks that the commands list is non-empty.
|
|
||||||
|
|
||||||
**Fix:** For each command entry during `initialize()`:
|
|
||||||
- Warn and skip the entry if `name` or `command` is missing.
|
|
||||||
- Extract the executable (first whitespace-delimited token of the command string).
|
|
||||||
- If the executable is an absolute path, check `os.path.isfile()` and `os.access(..., os.X_OK)`. Log a `WARNING` if either check fails.
|
|
||||||
- Commands with relative paths or shell builtins are not checked (they may be on PATH) — just noted.
|
|
||||||
- Validation warns only; all original entries in `self.commands` are retained and still attempted at collection time (where the existing missing-name/command guard already skips them). The plugin initializes successfully as long as the commands list is non-empty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. PluginLoader Messaging
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
When `initialize()` returns `False`, the loader always logs:
|
|
||||||
> `WARNING: Plugin X failed initialization, skipping`
|
|
||||||
|
|
||||||
This is alarming when the real reason is simply "no commands configured". There is no API to distinguish "not configured" from "genuinely broken".
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
Add an optional `skip_reason` attribute to `Plugin.__init__()` (defaults to `None`).
|
|
||||||
|
|
||||||
In `PluginLoader.load_from_directory()`, after `initialize()` returns `False`:
|
|
||||||
- If `plugin.skip_reason` is set → `logger.info(f"Plugin {plugin.name} skipped: {plugin.skip_reason}")`
|
|
||||||
- If `plugin.skip_reason` is `None` → `logger.warning(f"Plugin {plugin.name} failed initialization, skipping")` (existing behaviour)
|
|
||||||
|
|
||||||
In `NagiosRunnerPlugin.initialize()`, when no commands are configured:
|
|
||||||
```python
|
|
||||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
Genuine failures (exceptions) continue to go through the existing `except` block in the loader, logging at `ERROR` with traceback — unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
| Topic | Decision |
|
|
||||||
|---|---|
|
|
||||||
| Daemon log destination | syslog only (LOG_DAEMON facility) |
|
|
||||||
| Syslog fallback | localhost:514 UDP if `/dev/log` absent |
|
|
||||||
| Nagios result log level | INFO for all statuses (OK/WARNING/CRITICAL/UNKNOWN) |
|
|
||||||
| Invalid command handling at init | Warn and continue; still attempt at collection time |
|
|
||||||
| PluginLoader API change | `skip_reason` attribute on Plugin base class, checked by loader |
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# Gitea OAuth2 Authentication — Design Spec
|
|
||||||
|
|
||||||
Date: 2026-05-08
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add Gitea as an OAuth2 login provider alongside the existing username/password
|
|
||||||
authentication. Any user on the configured Gitea instance can sign in; their
|
|
||||||
local account is auto-provisioned on first login as a regular (non-admin) user.
|
|
||||||
Password login continues to work unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
A new optional `oauth.gitea` block in `~/.hb.yaml`. OAuth is disabled when the
|
|
||||||
block is absent or any of the three required keys is missing.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
gitea:
|
|
||||||
url: https://git.example.com # Gitea base URL, no trailing slash
|
|
||||||
client_id: <gitea-app-client-id>
|
|
||||||
client_secret: <gitea-app-client-secret>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Gitea setup:** Create an OAuth2 application in Gitea under
|
|
||||||
*Settings → Applications → OAuth2*. Set the redirect URI to
|
|
||||||
`https://<hbd-host>/login/oauth/gitea/callback`.
|
|
||||||
|
|
||||||
`config.py` default:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"oauth": {},
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New module: `hbd/server/oauth.py`
|
|
||||||
|
|
||||||
Owns all OAuth2 logic. No new dependencies — uses `aiohttp.ClientSession`
|
|
||||||
already present in the codebase.
|
|
||||||
|
|
||||||
### CSRF state store
|
|
||||||
|
|
||||||
```python
|
|
||||||
# state -> expires (float)
|
|
||||||
_states: dict[str, float] = {}
|
|
||||||
STATE_TTL = 600 # 10 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
`_states` is an in-memory dict. Entries are created on redirect and deleted on
|
|
||||||
use or expiry. A purge runs on every new state generation.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|---|---|
|
|
||||||
| `is_enabled(config)` | Returns `True` when url, client_id, and client_secret are all set |
|
|
||||||
| `make_state()` | Generates a random state token, stores it with TTL, returns it |
|
|
||||||
| `validate_state(state)` | Returns `True` and removes the state if valid and unexpired |
|
|
||||||
| `authorization_url(config, state, redirect_uri)` | Builds the Gitea `/login/oauth/authorize` redirect URL with `client_id`, `redirect_uri`, `scope=user:email`, `state` |
|
|
||||||
| `exchange_code(config, code, redirect_uri)` async | POSTs to Gitea `/login/oauth/access_token` with code and redirect_uri, returns the access token string or raises `OAuthError` |
|
|
||||||
| `fetch_user(config, token)` async | GETs Gitea `/api/v1/user` with Bearer token, returns `{"login", "full_name", "avatar_url"}` or raises `OAuthError` |
|
|
||||||
|
|
||||||
### Error handling
|
|
||||||
|
|
||||||
`OAuthError(message)` is a module-level exception. The callback route catches it
|
|
||||||
and renders the login page with an error message — identical to an invalid
|
|
||||||
password error in UX terms.
|
|
||||||
|
|
||||||
Network timeouts use a 10-second `aiohttp` timeout. Any non-2xx response from
|
|
||||||
Gitea raises `OAuthError`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Change: `hbd/server/users.py`
|
|
||||||
|
|
||||||
One new function added to the public API:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> User:
|
|
||||||
```
|
|
||||||
|
|
||||||
- If the username does not exist in the live `users` dict, creates a `User`
|
|
||||||
with no `password_hash` (so password login is impossible for this account)
|
|
||||||
and inserts it.
|
|
||||||
- If the username already exists (e.g. was defined in config with a password),
|
|
||||||
updates `full_name` and `avatar` from the OAuth profile and returns the
|
|
||||||
existing user unchanged in all other respects (preserving admin flag,
|
|
||||||
notification channels, etc.).
|
|
||||||
- Logs a one-line INFO message on first provision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes: `hbd/server/http.py`
|
|
||||||
|
|
||||||
### Two new route handlers
|
|
||||||
|
|
||||||
**`GET /login/oauth/gitea`**
|
|
||||||
|
|
||||||
1. Checks `oauth.is_enabled(config)` — returns 404 if not.
|
|
||||||
2. Calls `oauth.make_state()`.
|
|
||||||
3. Constructs `redirect_uri` as `{request.url.origin()}/login/oauth/gitea/callback` using aiohttp's `request.url.origin()`.
|
|
||||||
4. Redirects the browser to `oauth.authorization_url(config, state, redirect_uri)`.
|
|
||||||
|
|
||||||
**`GET /login/oauth/gitea/callback`**
|
|
||||||
|
|
||||||
1. Reads `code` and `state` query params; returns 400 if either is missing.
|
|
||||||
2. Calls `oauth.validate_state(state)` — redirects to `/login` with error if
|
|
||||||
invalid (CSRF or replay protection).
|
|
||||||
3. Reconstructs the same `redirect_uri` as the redirect handler (required by OAuth2 spec for token exchange).
|
|
||||||
4. Calls `await oauth.exchange_code(config, code, redirect_uri)` to get the access token.
|
|
||||||
4. Calls `await oauth.fetch_user(config, token)` to get the Gitea user profile.
|
|
||||||
5. Calls `users_mod.provision_oauth_user(login, full_name, avatar_url)`.
|
|
||||||
6. Calls `users_mod.create_session(username)` to get a session token.
|
|
||||||
7. Sets `hbd_session` cookie (same flags as password login: httponly, Lax,
|
|
||||||
24h TTL).
|
|
||||||
8. Redirects to `/`.
|
|
||||||
9. Any `OAuthError` re-renders the login page with a generic error message.
|
|
||||||
|
|
||||||
### Login page change
|
|
||||||
|
|
||||||
When `oauth.is_enabled(config)` is `True`, the existing login form gains a
|
|
||||||
separator and a "Sign in with Gitea" link button pointing to
|
|
||||||
`/login/oauth/gitea`. The password form is always rendered regardless.
|
|
||||||
|
|
||||||
### Route registration
|
|
||||||
|
|
||||||
```python
|
|
||||||
web.get("/login/oauth/gitea", oauth_redirect),
|
|
||||||
web.get("/login/oauth/gitea/callback", oauth_callback),
|
|
||||||
```
|
|
||||||
|
|
||||||
Added alongside the existing `/login` and `/logout` routes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser hbd Gitea
|
|
||||||
| | |
|
|
||||||
|-- GET /login ----------->| |
|
|
||||||
|<- login page (+ button) -| |
|
|
||||||
| | |
|
|
||||||
|-- GET /login/oauth/gitea>| |
|
|
||||||
|<- 302 Gitea /authorize --| |
|
|
||||||
| | |
|
|
||||||
|-- GET /login/oauth/authorize ----------------------->|
|
|
||||||
|<- 302 /login/oauth/gitea/callback?code=..&state=.. --|
|
|
||||||
| | |
|
|
||||||
|-- GET /callback -------->| |
|
|
||||||
| |-- POST /access_token ---->|
|
|
||||||
| |<- {access_token} ---------|
|
|
||||||
| |-- GET /api/v1/user ------>|
|
|
||||||
| |<- {login, name, avatar} --|
|
|
||||||
| | provision_oauth_user() |
|
|
||||||
| | create_session() |
|
|
||||||
|<- 302 / (set cookie) ----| |
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `test_oauth_state`: `make_state` + `validate_state` happy path; expired state
|
|
||||||
returns False; replay (double-use) returns False.
|
|
||||||
- `test_provision_oauth_user_new`: new username creates User with no password.
|
|
||||||
- `test_provision_oauth_user_existing`: existing config user updates name/avatar,
|
|
||||||
preserves admin flag and notification_channels.
|
|
||||||
- `test_oauth_callback_invalid_state`: callback with bad state redirects to login.
|
|
||||||
- Integration: mock Gitea endpoints with `aiohttp_client` fixture; full
|
|
||||||
redirect → callback → session cookie flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Restricting login to specific Gitea organisations or teams.
|
|
||||||
- Making OAuth users admin automatically.
|
|
||||||
- Multiple OAuth providers.
|
|
||||||
- Token refresh (Gitea access tokens are long-lived; the hbd session TTL governs
|
|
||||||
re-authentication).
|
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.2.5"
|
__version__ = "5.3.10"
|
||||||
|
|||||||
+33
-18
@@ -518,31 +518,43 @@ async def async_main(args, config):
|
|||||||
|
|
||||||
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
||||||
|
|
||||||
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
|
else 0)
|
||||||
|
|
||||||
# Create connections
|
# Create connections
|
||||||
connections = []
|
connections = []
|
||||||
conn_id = 1
|
conn_id = 1
|
||||||
|
_retry_delay = 5
|
||||||
for host in hb_hosts:
|
|
||||||
try:
|
|
||||||
addrs = socket.getaddrinfo(host, hb_port, 0, 0, socket.SOL_UDP)
|
|
||||||
except socket.gaierror as e:
|
|
||||||
logger.error(f"Cannot resolve {host}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for addr_info in addrs:
|
|
||||||
af = addr_info[0]
|
|
||||||
addr = addr_info[4][0]
|
|
||||||
|
|
||||||
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
while running and not connections:
|
||||||
if not await conn.open():
|
for host in hb_hosts:
|
||||||
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
|
try:
|
||||||
connections.append(conn)
|
addrs = socket.getaddrinfo(host, hb_port, af_filter, 0, socket.SOL_UDP)
|
||||||
conn_id += 1
|
except socket.gaierror as e:
|
||||||
|
logger.warning(f"Cannot resolve {host}: {e} — retrying in {_retry_delay}s")
|
||||||
|
continue
|
||||||
|
for addr_info in addrs:
|
||||||
|
af = addr_info[0]
|
||||||
|
addr = addr_info[4][0]
|
||||||
|
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
||||||
|
if not await conn.open():
|
||||||
|
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
|
||||||
|
connections.append(conn)
|
||||||
|
conn_id += 1
|
||||||
|
if not connections:
|
||||||
|
try:
|
||||||
|
if shutdown_event:
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=_retry_delay)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(_retry_delay)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
_retry_delay = min(_retry_delay * 2, 60)
|
||||||
|
|
||||||
if not connections:
|
if not connections:
|
||||||
logger.error("No connections established (DNS resolution failed for all hosts)")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(f"Created {len(connections)} connections")
|
logger.info(f"Created {len(connections)} connections")
|
||||||
|
|
||||||
# Send boot/message if requested
|
# Send boot/message if requested
|
||||||
@@ -726,6 +738,9 @@ def build_parser():
|
|||||||
default=0,
|
default=0,
|
||||||
help="Increase debug level"
|
help="Increase debug level"
|
||||||
)
|
)
|
||||||
|
af_group = parser.add_mutually_exclusive_group()
|
||||||
|
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
|
||||||
|
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"hosts",
|
"hosts",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
|
|||||||
@@ -146,8 +146,9 @@ thresholds:
|
|||||||
status:
|
status:
|
||||||
warning: 1 # Alert WARNING when pool is DEGRADED
|
warning: 1 # Alert WARNING when pool is DEGRADED
|
||||||
critical: 2 # Alert CRITICAL when pool is SUSPENDED/FAULTED/UNAVAIL
|
critical: 2 # Alert CRITICAL when pool is SUSPENDED/FAULTED/UNAVAIL
|
||||||
operator: ">"
|
operator: ">="
|
||||||
hysteresis: 0.0 # No hysteresis — a degraded pool is always critical
|
hysteresis: 0.0 # No hysteresis — a degraded pool is always alerting
|
||||||
|
grace: 0 # Fire immediately — don't wait for a second collection
|
||||||
display: "ZFS pool {pool_name} is {health}"
|
display: "ZFS pool {pool_name} is {health}"
|
||||||
|
|
||||||
# Per-pool capacity thresholds (optional; add pools you care about)
|
# Per-pool capacity thresholds (optional; add pools you care about)
|
||||||
|
|||||||
+27
-37
@@ -27,7 +27,7 @@ SERVER_DEFAULTS = {
|
|||||||
|
|
||||||
# Monitoring settings
|
# Monitoring settings
|
||||||
"interval": 20, # Expected heartbeat interval (for server checks)
|
"interval": 20, # Expected heartbeat interval (for server checks)
|
||||||
"grace": 2, # Grace multiplier (interval * grace = timeout)
|
"grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
|
||||||
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
||||||
|
|
||||||
# User management
|
# User management
|
||||||
@@ -39,15 +39,13 @@ SERVER_DEFAULTS = {
|
|||||||
|
|
||||||
# Host management
|
# Host management
|
||||||
"hosts": {}, # Unified host definitions
|
"hosts": {}, # Unified host definitions
|
||||||
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
"dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
|
||||||
"drophosts": [], # Hosts to ignore
|
|
||||||
"dyndomains": ["wrede.org"],
|
|
||||||
|
|
||||||
# DNS updates
|
# DNS updates
|
||||||
"nsupdate_bin": "/usr/bin/nsupdate",
|
"nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary
|
||||||
|
|
||||||
# WebSocket settings
|
# WebSocket settings
|
||||||
"ws_port": 50005,
|
"ws_port": 50005,
|
||||||
"wss_port": None,
|
"wss_port": None,
|
||||||
"cert_path": "/usr/local/etc/ssl/",
|
"cert_path": "/usr/local/etc/ssl/",
|
||||||
"wss_pem": "fullchain.pem",
|
"wss_pem": "fullchain.pem",
|
||||||
@@ -79,9 +77,13 @@ THRESHOLD_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'memory_monitor': {
|
'memory_monitor': {
|
||||||
'percent': {
|
'memory_percent': {
|
||||||
'warning': 85.0,
|
'warning': 85.0,
|
||||||
'critical': 95.0
|
'critical': 95.0
|
||||||
|
},
|
||||||
|
'swap_percent': {
|
||||||
|
'warning': 40.0,
|
||||||
|
'critical': 75.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'disk_monitor': {
|
'disk_monitor': {
|
||||||
@@ -109,11 +111,16 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'pools': {
|
'pools': {
|
||||||
'*': {
|
'*': {
|
||||||
'status': {
|
'status': {
|
||||||
'warning': 1,
|
'warning': 1,
|
||||||
'critical': 2,
|
'critical': 2,
|
||||||
'operator': '>',
|
'operator': '>=',
|
||||||
'hysteresis': 0.0,
|
'hysteresis': 0.0,
|
||||||
|
'grace': 0,
|
||||||
'display': 'ZFS pool {pool_name} is {health}'
|
'display': 'ZFS pool {pool_name} is {health}'
|
||||||
|
},
|
||||||
|
'capacity': {
|
||||||
|
'warning': 80.0,
|
||||||
|
'critical': 90.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +248,7 @@ def get_watchhosts(config):
|
|||||||
"""Extract watched hostnames from config (hosts with watch: true).
|
"""Extract watched hostnames from config (hosts with watch: true).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of hostnames to watch
|
# List of hostnames to watch
|
||||||
"""
|
"""
|
||||||
watchhosts = []
|
watchhosts = []
|
||||||
hosts_config = config.get("hosts", {})
|
hosts_config = config.get("hosts", {})
|
||||||
@@ -253,31 +260,14 @@ def get_watchhosts(config):
|
|||||||
|
|
||||||
|
|
||||||
def get_dyndnshosts(config):
|
def get_dyndnshosts(config):
|
||||||
"""Extract dyndnshosts from config, supporting both new and legacy formats.
|
"""Return hostnames that have a dyndns setting in the hosts section."""
|
||||||
|
hosts_config = config.get("hosts", {})
|
||||||
Args:
|
if not isinstance(hosts_config, dict):
|
||||||
config: Configuration dictionary
|
return []
|
||||||
|
return [
|
||||||
Returns:
|
name for name, attrs in hosts_config.items()
|
||||||
List of hostnames with dynamic DNS
|
if isinstance(attrs, dict) and attrs.get("dyndns")
|
||||||
"""
|
]
|
||||||
dyndnshosts = []
|
|
||||||
|
|
||||||
# New format: hosts section with dyndns attribute
|
|
||||||
if "hosts" in config:
|
|
||||||
hosts_config = config["hosts"]
|
|
||||||
if isinstance(hosts_config, dict):
|
|
||||||
for host_name, host_attrs in hosts_config.items():
|
|
||||||
if isinstance(host_attrs, dict) and host_attrs.get("dyndns", False):
|
|
||||||
dyndnshosts.append(host_name)
|
|
||||||
|
|
||||||
# Legacy format: dyndnshosts list/set
|
|
||||||
if "dyndnshosts" in config:
|
|
||||||
legacy_dyndnshosts = config.get("dyndnshosts", [])
|
|
||||||
if isinstance(legacy_dyndnshosts, (list, set)):
|
|
||||||
dyndnshosts.extend(legacy_dyndnshosts)
|
|
||||||
|
|
||||||
return list(set(dyndnshosts)) # Remove duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def get_host_config(config, hostname):
|
def get_host_config(config, hostname):
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""YAML round-trip read/write for .hb.yaml, with backup and atomic writes."""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
_write_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_yaml() -> YAML:
|
||||||
|
y = YAML()
|
||||||
|
y.preserve_quotes = True
|
||||||
|
return y
|
||||||
|
|
||||||
|
# Top-level keys managed by the 'server' logical section
|
||||||
|
_SERVER_KEYS = [
|
||||||
|
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
|
||||||
|
"interval", "grace", "base_url", "threshold_renotify_interval",
|
||||||
|
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
||||||
|
"journal_max_size", "journal_max_backups", "default_owner",
|
||||||
|
"default_threshold_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top-level keys managed by the 'dns' logical section
|
||||||
|
_DNS_KEYS = ["nsupdate_bin", "rndc_key", "dyndomains"]
|
||||||
|
|
||||||
|
|
||||||
|
def read_roundtrip(path: str):
|
||||||
|
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return _make_yaml().load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(path: str, data) -> None:
|
||||||
|
"""Backup current file then atomically write data.
|
||||||
|
|
||||||
|
Backup naming: {path}.bak.YYYYMMDD-HHMMSS
|
||||||
|
Rotation: keep the 10 most recent backups, delete older ones.
|
||||||
|
Atomic write: write to {path}.tmp then os.replace({path}.tmp, path).
|
||||||
|
Acquires _write_lock for the full backup+write sequence.
|
||||||
|
"""
|
||||||
|
with _write_lock:
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = f"{path}.bak.{ts}"
|
||||||
|
n = 0
|
||||||
|
while os.path.exists(backup_path):
|
||||||
|
n += 1
|
||||||
|
backup_path = f"{path}.bak.{ts}-{n}"
|
||||||
|
orig_mode = None
|
||||||
|
if os.path.exists(path):
|
||||||
|
orig_mode = os.stat(path).st_mode
|
||||||
|
with open(path, "rb") as src, open(backup_path, "wb") as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
os.chmod(backup_path, orig_mode)
|
||||||
|
backups = sorted(glob.glob(f"{path}.bak.*"), reverse=True)
|
||||||
|
for old in backups[10:]:
|
||||||
|
os.unlink(old)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
_make_yaml().dump(data, f)
|
||||||
|
if orig_mode is not None:
|
||||||
|
os.chmod(tmp, orig_mode)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(path: str) -> list:
|
||||||
|
"""Return backup paths sorted newest-first."""
|
||||||
|
return sorted(glob.glob(f"{path}.bak.*"), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_structured_section(data, section: str, values: dict) -> None:
|
||||||
|
"""Merge a dict of scalar/list values into data for the named logical section.
|
||||||
|
|
||||||
|
For 'server': updates each known key individually, preserving comments on
|
||||||
|
unchanged keys. For 'users': replaces the entire users dict.
|
||||||
|
"""
|
||||||
|
if section == "server":
|
||||||
|
for key in _SERVER_KEYS:
|
||||||
|
if key in values:
|
||||||
|
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":
|
||||||
|
data["users"] = values
|
||||||
|
elif section == "hosts":
|
||||||
|
data["hosts"] = values
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown structured section: {section!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_channel(data, name: str, channel_cfg: dict) -> None:
|
||||||
|
"""Insert or replace a single notification channel entry, preserving others."""
|
||||||
|
if not data.get("notification_channels"):
|
||||||
|
data["notification_channels"] = {}
|
||||||
|
data["notification_channels"][name] = channel_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def delete_channel(data, name: str) -> None:
|
||||||
|
"""Remove a notification channel by name. No-op if not found."""
|
||||||
|
nc = data.get("notification_channels") or {}
|
||||||
|
nc.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
|
||||||
|
"""Replace the named logical section by parsing yaml_text."""
|
||||||
|
parsed = _make_yaml().load(yaml_text)
|
||||||
|
if section == "notification_channels":
|
||||||
|
data["notification_channels"] = parsed
|
||||||
|
elif section == "thresholds":
|
||||||
|
data["threshold_configs"] = parsed
|
||||||
|
elif section == "hosts":
|
||||||
|
data["hosts"] = parsed
|
||||||
|
elif section == "dns":
|
||||||
|
if parsed:
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
if key in parsed:
|
||||||
|
data[key] = parsed[key]
|
||||||
|
else:
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
data.pop(key, None)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown YAML section: {section!r}")
|
||||||
+18
-15
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
from subprocess import Popen, PIPE, STDOUT
|
from subprocess import Popen, PIPE, STDOUT
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_nsupdate_payload(
|
def create_nsupdate_payload(
|
||||||
@@ -123,7 +126,6 @@ async def dns_update_worker(
|
|||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = f"changed address to {addr}"
|
|
||||||
for dyndomain in cfg.get("dyndomains", []):
|
for dyndomain in cfg.get("dyndomains", []):
|
||||||
err = await loop.run_in_executor(
|
err = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -135,28 +137,29 @@ async def dns_update_worker(
|
|||||||
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
m += f", DNS update failed: {err}"
|
m = f"DNS update failed for {addr} ({dyndomain}): {err}"
|
||||||
logger.error("DNS update failed for %s: %s", name, err)
|
logger.error("DNS update failed for %s: %s", name, err)
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "ERROR", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
m += ", DNS updated."
|
m = f"DNS updated {name}.dy.{dyndomain} → {addr}"
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "INFO", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not cfg.get("dyndomains"):
|
||||||
|
logger.warning("DNS update triggered for %s but no dyndomains configured", name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dnsq.task_done()
|
dnsq.task_done()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, name, m)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def start_dns_worker(
|
def start_dns_worker(
|
||||||
hbdclass,
|
hbdclass,
|
||||||
|
|||||||
+33
-1
@@ -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,6 +297,8 @@ 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
|
||||||
@@ -483,6 +485,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 +546,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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
+766
-26
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
|
import html as _html
|
||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
@@ -18,11 +19,87 @@ from . import settings as settings_mod
|
|||||||
from . import users as users_mod
|
from . import users as users_mod
|
||||||
from . import oauth as oauth_mod
|
from . import oauth as oauth_mod
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
|
from . import configio as configio_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
|
||||||
|
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
||||||
|
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
|
||||||
|
|
||||||
|
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
|
||||||
|
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for config_name, metrics in form_data.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
thresholds = {}
|
||||||
|
for metric_path, values in metrics.items():
|
||||||
|
_insert_threshold_metric(thresholds, metric_path, values)
|
||||||
|
result[config_name] = {"thresholds": thresholds}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -> None:
|
||||||
|
"""Insert a single metric into the nested threshold YAML structure."""
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = {}
|
||||||
|
op = values.get("operator", ">")
|
||||||
|
if op and op != ">":
|
||||||
|
cfg["operator"] = op
|
||||||
|
|
||||||
|
for key, cast in (("warning", float), ("critical", float), ("hysteresis", float)):
|
||||||
|
v = values.get(key)
|
||||||
|
if v is not None:
|
||||||
|
try:
|
||||||
|
cfg[key] = cast(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
grace = values.get("grace")
|
||||||
|
if grace is not None:
|
||||||
|
try:
|
||||||
|
cfg["grace"] = float(grace)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
count = values.get("count")
|
||||||
|
if count is not None:
|
||||||
|
try:
|
||||||
|
cfg["count"] = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
display = values.get("display", "")
|
||||||
|
if display:
|
||||||
|
cfg["display"] = display
|
||||||
|
|
||||||
|
if not values.get("enabled", True):
|
||||||
|
cfg["enabled"] = False
|
||||||
|
|
||||||
|
parts = metric_path.split(".", 2)
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
# e.g. "rtt"
|
||||||
|
thresholds[metric_path] = cfg
|
||||||
|
elif len(parts) == 2:
|
||||||
|
plugin, metric = parts
|
||||||
|
thresholds.setdefault(plugin, {})[metric] = cfg
|
||||||
|
else:
|
||||||
|
plugin, intermediate, leaf = parts
|
||||||
|
thresholds.setdefault(plugin, {})
|
||||||
|
if plugin == "disk_monitor":
|
||||||
|
thresholds[plugin].setdefault("partitions", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
elif plugin == "zfs_monitor":
|
||||||
|
thresholds[plugin].setdefault("pools", {}).setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
else:
|
||||||
|
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
|
||||||
def _render_template(html_str: str, **context) -> str:
|
def _render_template(html_str: str, **context) -> str:
|
||||||
tmpl = jinja2.Template(html_str)
|
tmpl = jinja2.Template(html_str)
|
||||||
return tmpl.render(**context)
|
return tmpl.render(**context)
|
||||||
@@ -100,6 +177,90 @@ def _can_own_host(user, host) -> bool:
|
|||||||
return host.is_owner(user.username)
|
return host.is_owner(user.username)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_config_for_api(config) -> dict:
|
||||||
|
"""Return a JSON-serializable config dict with secrets masked."""
|
||||||
|
result = {}
|
||||||
|
result["server"] = {k: config.get(k) for k in configio_mod._SERVER_KEYS}
|
||||||
|
|
||||||
|
users = {}
|
||||||
|
for username, attrs in (config.get("users") or {}).items():
|
||||||
|
u = dict(attrs)
|
||||||
|
if "password" in u:
|
||||||
|
u["password"] = "•••"
|
||||||
|
users[username] = u
|
||||||
|
result["users"] = users
|
||||||
|
|
||||||
|
oauth = {}
|
||||||
|
for name, attrs in (config.get("oauth") or {}).items():
|
||||||
|
o = dict(attrs)
|
||||||
|
if "client_secret" in o:
|
||||||
|
o["client_secret"] = "•••"
|
||||||
|
oauth[name] = o
|
||||||
|
result["oauth"] = oauth
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
|
hbc_version = None
|
||||||
|
hbc_type = None
|
||||||
|
latest_os = host.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
_, os_data = latest_os
|
||||||
|
hbc_version = os_data.get("hbc_version")
|
||||||
|
hbc_type = os_data.get("hbc_type")
|
||||||
|
|
||||||
|
last_packet = None
|
||||||
|
if host.connections:
|
||||||
|
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||||
|
|
||||||
|
thresholds = None
|
||||||
|
if threshold_checker is not None:
|
||||||
|
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||||
|
|
||||||
|
# Build reverse coverage: which metric paths suffix-match to each threshold.
|
||||||
|
# Mirrors the logic in ThresholdChecker._find_threshold.
|
||||||
|
coverage: dict = {}
|
||||||
|
for plugin_name, samples in host.plugin_data.items():
|
||||||
|
if not samples:
|
||||||
|
continue
|
||||||
|
_, pdata = samples[-1]
|
||||||
|
for field_name in pdata:
|
||||||
|
full_path = f"{plugin_name}.{field_name}"
|
||||||
|
if full_path in raw:
|
||||||
|
continue # exact match — the threshold IS this metric
|
||||||
|
parts = field_name.split("_")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
candidate = f"{plugin_name}." + "_".join(parts[i:])
|
||||||
|
if candidate in raw:
|
||||||
|
coverage.setdefault(candidate, []).append(full_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
thresholds = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
"covers": sorted(coverage.get(tc.metric_path, [])),
|
||||||
|
}
|
||||||
|
for tc in raw.values()
|
||||||
|
],
|
||||||
|
key=lambda x: x["metric"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": getattr(host, "owner", None),
|
||||||
|
"managers": list(getattr(host, "managers", [])),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
|
"hbc_type": hbc_type,
|
||||||
|
"last_packet": last_packet,
|
||||||
|
"thresholds": thresholds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -110,6 +271,7 @@ async def start(
|
|||||||
get_now=None,
|
get_now=None,
|
||||||
VER="",
|
VER="",
|
||||||
threshold_checker=None,
|
threshold_checker=None,
|
||||||
|
reload_callback=None,
|
||||||
):
|
):
|
||||||
"""Start an aiohttp web server and block until cancelled.
|
"""Start an aiohttp web server and block until cancelled.
|
||||||
|
|
||||||
@@ -163,6 +325,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()}
|
||||||
@@ -433,6 +597,8 @@ async def start(
|
|||||||
all_alerts = []
|
all_alerts = []
|
||||||
|
|
||||||
for hostname, host in hbdclass.Host.hosts.items():
|
for hostname, host in hbdclass.Host.hosts.items():
|
||||||
|
if not host.watched:
|
||||||
|
continue
|
||||||
if not _can_view_host(user, host):
|
if not _can_view_host(user, host):
|
||||||
continue
|
continue
|
||||||
if threshold_checker:
|
if threshold_checker:
|
||||||
@@ -588,6 +754,7 @@ async def start(
|
|||||||
if user is None:
|
if user is None:
|
||||||
return web.json_response({"error": "Invalid credentials"}, status=401)
|
return web.json_response({"error": "Invalid credentials"}, status=401)
|
||||||
token = users_mod.create_session(username)
|
token = users_mod.create_session(username)
|
||||||
|
eventlog("hbd", "INFO", f"Login: {username} via api")
|
||||||
resp = web.json_response({"token": token, "username": username})
|
resp = web.json_response({"token": token, "username": username})
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -611,6 +778,7 @@ async def start(
|
|||||||
user = users_mod.authenticate(username, password)
|
user = users_mod.authenticate(username, password)
|
||||||
if user:
|
if user:
|
||||||
token = users_mod.create_session(username)
|
token = users_mod.create_session(username)
|
||||||
|
eventlog("hbd", "INFO", f"Login: {username} via password")
|
||||||
redirect_to = request.rel_url.query.get("next", "/")
|
redirect_to = request.rel_url.query.get("next", "/")
|
||||||
resp = web.HTTPFound(redirect_to)
|
resp = web.HTTPFound(redirect_to)
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
@@ -625,13 +793,18 @@ async def start(
|
|||||||
elif request.rel_url.query.get("error"):
|
elif request.rel_url.query.get("error"):
|
||||||
error = "Sign-in failed. Please try again."
|
error = "Sign-in failed. Please try again."
|
||||||
|
|
||||||
gitea_button = ""
|
oauth_buttons = ""
|
||||||
if oauth_mod.is_enabled(config):
|
_providers = oauth_mod.get_providers(config)
|
||||||
gitea_button = f"""
|
if _providers:
|
||||||
<div class="divider">or</div>
|
buttons_html = ""
|
||||||
<a href="/login/oauth/gitea" class="gitea-btn">
|
for _p in _providers:
|
||||||
Sign in with Gitea
|
_logo = f'<img src="{_html.escape(_p.logo)}" alt="" class="oauth-logo">' if _p.logo else ""
|
||||||
|
buttons_html += f"""
|
||||||
|
<a href="/login/oauth/{_html.escape(_p.name)}" class="oauth-btn">
|
||||||
|
{_logo}{_html.escape(_p.label)}
|
||||||
</a>"""
|
</a>"""
|
||||||
|
oauth_buttons = f"""
|
||||||
|
<div class="divider">or</div>{buttons_html}"""
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -654,10 +827,12 @@ async def start(
|
|||||||
.field {{ margin-bottom: .9em; }}
|
.field {{ margin-bottom: .9em; }}
|
||||||
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
||||||
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
||||||
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926;
|
.oauth-btn {{ display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: .5em; width: 100%; padding: .6em; background: #16191d;
|
||||||
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
|
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
|
||||||
text-decoration: none; box-sizing: border-box; }}
|
text-decoration: none; box-sizing: border-box; margin-top: .5em; }}
|
||||||
.gitea-btn:hover {{ background: #4e7d1e; }}
|
.oauth-btn:hover {{ background: #444; }}
|
||||||
|
.oauth-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -668,7 +843,7 @@ async def start(
|
|||||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
||||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>{gitea_button}
|
</form>{oauth_buttons}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -677,7 +852,10 @@ async def start(
|
|||||||
async def web_logout(request):
|
async def web_logout(request):
|
||||||
"""GET /logout — clear session cookie and redirect to /login."""
|
"""GET /logout — clear session cookie and redirect to /login."""
|
||||||
token = request.cookies.get(SESSION_COOKIE, "")
|
token = request.cookies.get(SESSION_COOKIE, "")
|
||||||
|
_user = users_mod.get_session_user(token)
|
||||||
users_mod.delete_session(token)
|
users_mod.delete_session(token)
|
||||||
|
if _user:
|
||||||
|
eventlog("hbd", "INFO", f"Logout: {_user.username}")
|
||||||
resp = web.HTTPFound("/login")
|
resp = web.HTTPFound("/login")
|
||||||
resp.del_cookie(SESSION_COOKIE)
|
resp.del_cookie(SESSION_COOKIE)
|
||||||
raise resp
|
raise resp
|
||||||
@@ -685,7 +863,10 @@ async def start(
|
|||||||
async def api_logout(request):
|
async def api_logout(request):
|
||||||
"""POST /api/0/auth/logout"""
|
"""POST /api/0/auth/logout"""
|
||||||
token = _get_token(request)
|
token = _get_token(request)
|
||||||
|
_user = users_mod.get_session_user(token)
|
||||||
users_mod.delete_session(token)
|
users_mod.delete_session(token)
|
||||||
|
if _user:
|
||||||
|
eventlog("hbd", "INFO", f"Logout: {_user.username}")
|
||||||
resp = web.json_response({"success": True})
|
resp = web.json_response({"success": True})
|
||||||
resp.del_cookie(SESSION_COOKIE)
|
resp.del_cookie(SESSION_COOKIE)
|
||||||
return resp
|
return resp
|
||||||
@@ -787,6 +968,23 @@ async def start(
|
|||||||
|
|
||||||
return web.json_response(host.access_dict())
|
return web.json_response(host.access_dict())
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Host info endpoint
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def api_host_info(request):
|
||||||
|
"""GET /api/0/hosts/{hostname}/info"""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
hostname = request.match_info.get("hostname")
|
||||||
|
if hostname not in hbdclass.Host.hosts:
|
||||||
|
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||||
|
host = hbdclass.Host.hosts[hostname]
|
||||||
|
if not _can_view_host(user, host):
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# User profile page
|
# User profile page
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -838,6 +1036,24 @@ async def start(
|
|||||||
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
||||||
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
||||||
|
|
||||||
|
# Build visible channels list for chip picker and My Channels management.
|
||||||
|
visible_channels = _visible_channels_for_user(current_user) if current_user else {}
|
||||||
|
all_channels = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"type": cfg.get("type", ""),
|
||||||
|
"owner": cfg.get("owner"),
|
||||||
|
"private": bool(cfg.get("private", False)),
|
||||||
|
}
|
||||||
|
for name, cfg in visible_channels.items()
|
||||||
|
if isinstance(cfg, dict)
|
||||||
|
],
|
||||||
|
key=lambda c: c["name"],
|
||||||
|
)
|
||||||
|
# Keep all_channel_names for backwards-compat with any template references.
|
||||||
|
all_channel_names = [c["name"] for c in all_channels]
|
||||||
|
|
||||||
tmpl = env.get_template("profile.html")
|
tmpl = env.get_template("profile.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
title="Profile - Heartbeat",
|
title="Profile - Heartbeat",
|
||||||
@@ -847,6 +1063,8 @@ async def start(
|
|||||||
managed_hosts=managed,
|
managed_hosts=managed,
|
||||||
monitored_hosts=monitored,
|
monitored_hosts=monitored,
|
||||||
notification_channels=notif_channels,
|
notification_channels=notif_channels,
|
||||||
|
all_channels=all_channels,
|
||||||
|
all_channel_names=all_channel_names,
|
||||||
active_page="profile",
|
active_page="profile",
|
||||||
)
|
)
|
||||||
return web.Response(text=body, content_type="text/html")
|
return web.Response(text=body, content_type="text/html")
|
||||||
@@ -906,29 +1124,46 @@ async def start(
|
|||||||
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))
|
||||||
tmpl = env.get_template("settings.html")
|
tmpl = env.get_template("settings.html")
|
||||||
|
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
title="Settings - Heartbeat",
|
title="Settings - Heartbeat",
|
||||||
sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker),
|
sections=settings_data["sections"],
|
||||||
|
all_channel_names=settings_data["all_channel_names"],
|
||||||
|
all_usernames=settings_data["all_usernames"],
|
||||||
|
all_threshold_configs=settings_data["all_threshold_configs"],
|
||||||
current_user=current_user.to_dict() if current_user else None,
|
current_user=current_user.to_dict() if current_user else None,
|
||||||
active_page="settings",
|
active_page="settings",
|
||||||
)
|
)
|
||||||
return web.Response(text=body, content_type="text/html")
|
return web.Response(text=body, content_type="text/html")
|
||||||
|
|
||||||
def _oauth_redirect_uri(request) -> str:
|
def _oauth_redirect_uri(request, provider_name: str) -> str:
|
||||||
base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
|
base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
|
||||||
return f"{base}/login/oauth/gitea/callback"
|
return f"{base}/login/oauth/{provider_name}/callback"
|
||||||
|
|
||||||
async def oauth_gitea_redirect(request):
|
def _get_oauth_provider(name: str):
|
||||||
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
|
"""Return the ResolvedProvider for *name*, or None if not found."""
|
||||||
if not oauth_mod.is_enabled(config):
|
return next(
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
(p for p in oauth_mod.get_providers(config) if p.name == name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def oauth_redirect(request):
|
||||||
|
"""GET /login/oauth/{name} — kick off the OAuth2 flow for the named provider."""
|
||||||
|
name = request.match_info["name"]
|
||||||
|
provider = _get_oauth_provider(name)
|
||||||
|
if provider is None:
|
||||||
|
return web.Response(status=404, text="OAuth provider not found")
|
||||||
state = oauth_mod.make_state()
|
state = oauth_mod.make_state()
|
||||||
raise web.HTTPFound(oauth_mod.authorization_url(config, state, _oauth_redirect_uri(request)))
|
raise web.HTTPFound(
|
||||||
|
oauth_mod.build_auth_url(provider, state, _oauth_redirect_uri(request, name))
|
||||||
|
)
|
||||||
|
|
||||||
async def oauth_gitea_callback(request):
|
async def oauth_callback(request):
|
||||||
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
|
"""GET /login/oauth/{name}/callback — handle the provider's redirect back."""
|
||||||
if not oauth_mod.is_enabled(config):
|
name = request.match_info["name"]
|
||||||
return web.Response(status=404, text="OAuth not configured")
|
provider = _get_oauth_provider(name)
|
||||||
|
if provider is None:
|
||||||
|
return web.Response(status=404, text="OAuth provider not found")
|
||||||
code = request.rel_url.query.get("code", "")
|
code = request.rel_url.query.get("code", "")
|
||||||
state = request.rel_url.query.get("state", "")
|
state = request.rel_url.query.get("state", "")
|
||||||
if not code or not state:
|
if not code or not state:
|
||||||
@@ -937,8 +1172,8 @@ async def start(
|
|||||||
logger.warning("OAuth: invalid or expired state token from %s", request.remote)
|
logger.warning("OAuth: invalid or expired state token from %s", request.remote)
|
||||||
raise web.HTTPFound("/login?error=1")
|
raise web.HTTPFound("/login?error=1")
|
||||||
try:
|
try:
|
||||||
token = await oauth_mod.exchange_code(config, code, _oauth_redirect_uri(request))
|
token = await oauth_mod.exchange_code(provider, code, _oauth_redirect_uri(request, name))
|
||||||
profile = await oauth_mod.fetch_user(config, token)
|
profile = await oauth_mod.fetch_user(provider, token)
|
||||||
except oauth_mod.OAuthError as exc:
|
except oauth_mod.OAuthError as exc:
|
||||||
logger.warning("OAuth error: %s", exc)
|
logger.warning("OAuth error: %s", exc)
|
||||||
raise web.HTTPFound("/login?error=1")
|
raise web.HTTPFound("/login?error=1")
|
||||||
@@ -947,7 +1182,25 @@ 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}")
|
||||||
resp = web.HTTPFound("/")
|
resp = web.HTTPFound("/")
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -958,6 +1211,479 @@ async def start(
|
|||||||
)
|
)
|
||||||
raise resp
|
raise resp
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Config API (admin only)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_config_path = getattr(config, "_config_path", "") or ""
|
||||||
|
|
||||||
|
async def api_config_get(request):
|
||||||
|
"""GET /api/0/config — full config as JSON, secrets masked. Admin only."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user and not user.admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
return web.json_response(_mask_config_for_api(config))
|
||||||
|
|
||||||
|
_YAML_EXTRACTORS = {
|
||||||
|
"notification_channels": lambda d: d.get("notification_channels") or {},
|
||||||
|
"thresholds": lambda d: d.get("threshold_configs") or {},
|
||||||
|
"hosts": lambda d: d.get("hosts") or {},
|
||||||
|
"dns": lambda d: {k: d[k] for k in configio_mod._DNS_KEYS if k in d},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def api_config_section_get(request):
|
||||||
|
"""GET /api/0/config/section/{name} — raw YAML text for a YAML-editor section."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user and not user.admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
name = request.match_info["name"]
|
||||||
|
if name not in _YAML_EXTRACTORS:
|
||||||
|
return web.json_response({"error": "Unknown section"}, status=404)
|
||||||
|
|
||||||
|
import io as _io
|
||||||
|
from ruamel.yaml import YAML as _YAML
|
||||||
|
try:
|
||||||
|
data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
section_data = _YAML_EXTRACTORS[name](data)
|
||||||
|
_sy = _YAML()
|
||||||
|
_sy.preserve_quotes = True
|
||||||
|
buf = _io.StringIO()
|
||||||
|
_sy.dump(section_data, buf)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Config section read failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
return web.json_response({"yaml": buf.getvalue()})
|
||||||
|
|
||||||
|
async def api_config_backups_get(request):
|
||||||
|
"""GET /api/0/config/backups — list of backup paths, newest first."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user and not user.admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"backups": []})
|
||||||
|
backups = configio_mod.list_backups(_config_path)
|
||||||
|
return web.json_response({"backups": backups})
|
||||||
|
|
||||||
|
async def api_config_post(request):
|
||||||
|
"""POST /api/0/config — publish staged changes to .hb.yaml. Admin only."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user and not user.admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
|
||||||
|
if "server" in payload:
|
||||||
|
configio_mod.apply_structured_section(data, "server", payload["server"])
|
||||||
|
|
||||||
|
if "users" in payload:
|
||||||
|
# Hash any plaintext passwords; preserve existing hashes when omitted or "•••"
|
||||||
|
existing_users = data.get("users") or {}
|
||||||
|
users_payload = payload["users"]
|
||||||
|
for username, attrs in users_payload.items():
|
||||||
|
pw = attrs.get("password", "")
|
||||||
|
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
|
||||||
|
attrs["password"] = users_mod.hash_password(pw)
|
||||||
|
elif not pw or pw == "•••":
|
||||||
|
existing_hash = (existing_users.get(username) or {}).get("password", "")
|
||||||
|
if existing_hash:
|
||||||
|
attrs["password"] = existing_hash
|
||||||
|
else:
|
||||||
|
attrs.pop("password", None)
|
||||||
|
configio_mod.apply_structured_section(data, "users", users_payload)
|
||||||
|
|
||||||
|
if "oauth" in payload:
|
||||||
|
existing_oauth = data.get("oauth") or {}
|
||||||
|
new_oauth = payload["oauth"]
|
||||||
|
for name, attrs in new_oauth.items():
|
||||||
|
cs = attrs.get("client_secret", "")
|
||||||
|
if not cs or cs == "•••":
|
||||||
|
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
|
||||||
|
if existing_cs:
|
||||||
|
attrs["client_secret"] = existing_cs
|
||||||
|
else:
|
||||||
|
attrs.pop("client_secret", None)
|
||||||
|
data["oauth"] = new_oauth
|
||||||
|
|
||||||
|
if "notification_channels" in payload:
|
||||||
|
configio_mod.apply_yaml_section(data, "notification_channels", payload["notification_channels"])
|
||||||
|
|
||||||
|
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:
|
||||||
|
tc = payload["thresholds"]
|
||||||
|
if isinstance(tc, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
|
elif isinstance(tc, dict):
|
||||||
|
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
||||||
|
|
||||||
|
if "hosts" in payload:
|
||||||
|
h = payload["hosts"]
|
||||||
|
if isinstance(h, dict):
|
||||||
|
configio_mod.apply_structured_section(data, "hosts", h)
|
||||||
|
else:
|
||||||
|
configio_mod.apply_yaml_section(data, "hosts", h)
|
||||||
|
|
||||||
|
configio_mod.write_config(_config_path, data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Config write failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
users_mod.load_users(config)
|
||||||
|
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
async def api_config_rollback(request):
|
||||||
|
"""POST /api/0/config/rollback — restore a backup. Admin only."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user and not user.admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
backup = body.get("backup", "")
|
||||||
|
if not backup or backup not in configio_mod.list_backups(_config_path):
|
||||||
|
return web.json_response({"error": "Invalid or missing backup"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_data = configio_mod.read_roundtrip(backup)
|
||||||
|
configio_mod.write_config(_config_path, backup_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Rollback failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
users_mod.load_users(config)
|
||||||
|
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Notification channel helpers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _visible_channels_for_user(user):
|
||||||
|
"""Return {name: cfg} of channels visible to user (public + own private)."""
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user is None:
|
||||||
|
return {}
|
||||||
|
if user.admin:
|
||||||
|
return dict(all_channels)
|
||||||
|
visible = {}
|
||||||
|
for name, cfg in all_channels.items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
if not cfg.get("private") or cfg.get("owner") == user.username:
|
||||||
|
visible[name] = cfg
|
||||||
|
return visible
|
||||||
|
|
||||||
|
def _build_channel_response(ch_name, ch_cfg):
|
||||||
|
"""Serialize a channel config dict for the API response."""
|
||||||
|
ch_type = ch_cfg.get("type", "")
|
||||||
|
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
|
||||||
|
fields = []
|
||||||
|
for sf in schema_fields:
|
||||||
|
k = sf["key"]
|
||||||
|
v = ch_cfg.get(k, "")
|
||||||
|
sensitive = sf["type"] == "secret"
|
||||||
|
fields.append({
|
||||||
|
"key": k,
|
||||||
|
"label": sf["label"],
|
||||||
|
"value": "•••" if (sensitive and v) else (
|
||||||
|
", ".join(v) if isinstance(v, list) else str(v or "")
|
||||||
|
),
|
||||||
|
"sensitive": sensitive,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"name": ch_name,
|
||||||
|
"type": ch_type,
|
||||||
|
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
|
"owner": ch_cfg.get("owner"),
|
||||||
|
"private": bool(ch_cfg.get("private", False)),
|
||||||
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Notification channel API (any authenticated user)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def api_notification_channel_types(request):
|
||||||
|
"""GET /api/0/notification_channel_types — channel type schemas."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS)
|
||||||
|
|
||||||
|
async def api_notification_channels_get(request):
|
||||||
|
"""GET /api/0/notification_channels — list channels visible to current user."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
visible = _visible_channels_for_user(user)
|
||||||
|
result = [
|
||||||
|
_build_channel_response(name, cfg)
|
||||||
|
for name, cfg in visible.items()
|
||||||
|
if isinstance(cfg, dict)
|
||||||
|
]
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
async def api_notification_channels_post(request):
|
||||||
|
"""POST /api/0/notification_channels — create a new channel."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user is None:
|
||||||
|
return web.json_response({"error": "Authentication required"}, status=401)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return web.json_response({"error": "Channel name is required"}, status=400)
|
||||||
|
ch_type = (body.get("type") or "").strip()
|
||||||
|
if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS:
|
||||||
|
return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400)
|
||||||
|
if name in (config.get("notification_channels") or {}):
|
||||||
|
return web.json_response({"error": f"Channel {name!r} already exists"}, status=409)
|
||||||
|
|
||||||
|
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type]
|
||||||
|
channel_cfg = {"type": ch_type}
|
||||||
|
for sf in schema["fields"]:
|
||||||
|
k = sf["key"]
|
||||||
|
v = body.get(k, "")
|
||||||
|
if v:
|
||||||
|
channel_cfg[k] = v
|
||||||
|
elif sf["required"]:
|
||||||
|
return web.json_response({"error": f"Field {k!r} is required"}, status=400)
|
||||||
|
|
||||||
|
if body.get("min_level"):
|
||||||
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
|
channel_cfg["owner"] = user.username
|
||||||
|
if body.get("private"):
|
||||||
|
channel_cfg["private"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
configio_mod.apply_channel(disk_data, name, channel_cfg)
|
||||||
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Channel create failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
return web.json_response({"ok": True, "name": name})
|
||||||
|
|
||||||
|
async def api_notification_channel_put(request):
|
||||||
|
"""PUT /api/0/notification_channels/{name} — update a channel."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user is None:
|
||||||
|
return web.json_response({"error": "Authentication required"}, status=401)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
ch_name = request.match_info["name"]
|
||||||
|
existing_channels = config.get("notification_channels") or {}
|
||||||
|
if ch_name not in existing_channels:
|
||||||
|
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
|
||||||
|
|
||||||
|
existing_cfg = existing_channels[ch_name]
|
||||||
|
if not isinstance(existing_cfg, dict):
|
||||||
|
return web.json_response({"error": "Invalid channel config"}, status=500)
|
||||||
|
|
||||||
|
owner = existing_cfg.get("owner")
|
||||||
|
if not user.admin and owner != user.username:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
ch_type = existing_cfg.get("type", "")
|
||||||
|
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
|
||||||
|
secret_keys = {sf["key"] for sf in schema_fields if sf["type"] == "secret"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
existing_on_disk = (disk_data.get("notification_channels") or {}).get(ch_name, {})
|
||||||
|
|
||||||
|
channel_cfg = {"type": ch_type}
|
||||||
|
for sf in schema_fields:
|
||||||
|
k = sf["key"]
|
||||||
|
v = body.get(k, "")
|
||||||
|
if k in secret_keys and (not v or v == "•••"):
|
||||||
|
existing_val = existing_on_disk.get(k, "")
|
||||||
|
if existing_val:
|
||||||
|
channel_cfg[k] = existing_val
|
||||||
|
elif v:
|
||||||
|
channel_cfg[k] = v
|
||||||
|
|
||||||
|
if body.get("min_level"):
|
||||||
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
|
if owner is not None:
|
||||||
|
channel_cfg["owner"] = owner
|
||||||
|
if "private" in body:
|
||||||
|
channel_cfg["private"] = bool(body["private"])
|
||||||
|
elif existing_on_disk.get("private"):
|
||||||
|
channel_cfg["private"] = True
|
||||||
|
|
||||||
|
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
|
||||||
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Channel update failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
async def api_notification_channel_delete(request):
|
||||||
|
"""DELETE /api/0/notification_channels/{name} — delete a channel."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user is None:
|
||||||
|
return web.json_response({"error": "Authentication required"}, status=401)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
ch_name = request.match_info["name"]
|
||||||
|
existing_channels = config.get("notification_channels") or {}
|
||||||
|
if ch_name not in existing_channels:
|
||||||
|
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
|
||||||
|
|
||||||
|
existing_cfg = existing_channels[ch_name]
|
||||||
|
owner = existing_cfg.get("owner") if isinstance(existing_cfg, dict) else None
|
||||||
|
if not user.admin and owner != user.username:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
configio_mod.delete_channel(disk_data, ch_name)
|
||||||
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Channel delete failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
async def api_user_self_put(request):
|
||||||
|
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user is None:
|
||||||
|
return web.json_response({"error": "Authentication required"}, status=401)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
password_change = body.get("password")
|
||||||
|
|
||||||
|
if password_change:
|
||||||
|
if not isinstance(password_change, dict):
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
current_pw = password_change.get("current", "")
|
||||||
|
new_pw = password_change.get("new", "")
|
||||||
|
if not new_pw:
|
||||||
|
return web.json_response({"error": "New password cannot be empty"}, status=400)
|
||||||
|
if not users_mod.authenticate(username, current_pw):
|
||||||
|
return web.json_response({"error": "Current password incorrect"}, status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
if "users" not in data or data["users"] is None:
|
||||||
|
data["users"] = {}
|
||||||
|
user_entry = dict(data["users"].get(username) or {})
|
||||||
|
|
||||||
|
if "full_name" in body:
|
||||||
|
user_entry["full_name"] = str(body["full_name"])
|
||||||
|
if "avatar" in body:
|
||||||
|
user_entry["avatar"] = str(body["avatar"])
|
||||||
|
if "notification_channels" in body:
|
||||||
|
visible = _visible_channels_for_user(user)
|
||||||
|
user_entry["notification_channels"] = [
|
||||||
|
str(ch) for ch in body["notification_channels"] if ch in visible
|
||||||
|
]
|
||||||
|
if password_change:
|
||||||
|
user_entry["password"] = users_mod.hash_password(password_change["new"])
|
||||||
|
|
||||||
|
data["users"][username] = user_entry
|
||||||
|
configio_mod.write_config(_config_path, data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("User self-update failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
|
await config.reload()
|
||||||
|
users_mod.load_users(config)
|
||||||
|
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.add_routes(
|
app.add_routes(
|
||||||
[
|
[
|
||||||
@@ -969,12 +1695,25 @@ async def start(
|
|||||||
web.get("/logout", web_logout),
|
web.get("/logout", web_logout),
|
||||||
web.post("/api/0/auth/login", api_login),
|
web.post("/api/0/auth/login", api_login),
|
||||||
web.post("/api/0/auth/logout", api_logout),
|
web.post("/api/0/auth/logout", api_logout),
|
||||||
web.get("/login/oauth/gitea", oauth_gitea_redirect),
|
web.get("/login/oauth/{name}", oauth_redirect),
|
||||||
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
|
web.get("/login/oauth/{name}/callback", oauth_callback),
|
||||||
# Users
|
# Users
|
||||||
web.get("/api/0/users", api_users),
|
web.get("/api/0/users", api_users),
|
||||||
web.get("/api/0/users/me", api_user_self),
|
web.get("/api/0/users/me", api_user_self),
|
||||||
|
web.put("/api/0/users/me", api_user_self_put),
|
||||||
web.get("/api/0/users/{username}/avatar", api_user_avatar),
|
web.get("/api/0/users/{username}/avatar", api_user_avatar),
|
||||||
|
# Config API (admin)
|
||||||
|
web.get("/api/0/config", api_config_get),
|
||||||
|
web.get("/api/0/config/section/{name}", api_config_section_get),
|
||||||
|
web.get("/api/0/config/backups", api_config_backups_get),
|
||||||
|
web.post("/api/0/config", api_config_post),
|
||||||
|
web.post("/api/0/config/rollback", api_config_rollback),
|
||||||
|
# Notification channel API (any authenticated user)
|
||||||
|
web.get("/api/0/notification_channel_types", api_notification_channel_types),
|
||||||
|
web.get("/api/0/notification_channels", api_notification_channels_get),
|
||||||
|
web.post("/api/0/notification_channels", api_notification_channels_post),
|
||||||
|
web.put("/api/0/notification_channels/{name}", api_notification_channel_put),
|
||||||
|
web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete),
|
||||||
# Hosts
|
# Hosts
|
||||||
web.get("/api/0/hosts", api_hosts),
|
web.get("/api/0/hosts", api_hosts),
|
||||||
web.get("/api/0/alert_summary", api_alert_summary),
|
web.get("/api/0/alert_summary", api_alert_summary),
|
||||||
@@ -984,6 +1723,7 @@ async def start(
|
|||||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||||
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
||||||
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
||||||
|
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||||
web.get("/api/0/alerts", api_all_alerts),
|
web.get("/api/0/alerts", api_all_alerts),
|
||||||
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
||||||
web.get("/c", cmd),
|
web.get("/c", cmd),
|
||||||
|
|||||||
+5
-9
@@ -78,9 +78,7 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
True if reload succeeded, False otherwise
|
True if reload succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Starting configuration reload...")
|
logger.info("Starting configuration reload...")
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
# Reload config file
|
# Reload config file
|
||||||
new_config = await config_obj.reload(config_path)
|
new_config = await config_obj.reload(config_path)
|
||||||
@@ -115,13 +113,11 @@ async def reload_configuration(config_obj, config_path, components):
|
|||||||
# These are reloadable and effective immediately:
|
# These are reloadable and effective immediately:
|
||||||
# - notification_channels
|
# - notification_channels
|
||||||
# - threshold_configs
|
# - threshold_configs
|
||||||
# - hosts (watchhosts, dyndnshosts, notification_channels)
|
# - hosts (watchhosts, dyndns, notification_channels)
|
||||||
# - grace period (used on next heartbeat)
|
# - grace period (used on next heartbeat)
|
||||||
# - debug/verbose flags (used on next message)
|
# - debug/verbose flags (used on next message)
|
||||||
|
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("Configuration reload completed successfully")
|
logger.info("Configuration reload completed successfully")
|
||||||
logger.info("=" * 60)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -246,6 +242,9 @@ async def _run_async(config, config_path=None):
|
|||||||
# upgrade or config change between runs).
|
# upgrade or config change between runs).
|
||||||
threshold_checker.purge_stale_alerts(hbdclass)
|
threshold_checker.purge_stale_alerts(hbdclass)
|
||||||
|
|
||||||
|
async def _http_reload_callback():
|
||||||
|
await reload_configuration(config, config_path, components)
|
||||||
|
|
||||||
# HTTP server (asyncio-based via aiohttp)
|
# HTTP server (asyncio-based via aiohttp)
|
||||||
try:
|
try:
|
||||||
http_task = asyncio.create_task(
|
http_task = asyncio.create_task(
|
||||||
@@ -259,6 +258,7 @@ async def _run_async(config, config_path=None):
|
|||||||
verbose=config.get("verbose", False),
|
verbose=config.get("verbose", False),
|
||||||
get_now=lambda: time.time(),
|
get_now=lambda: time.time(),
|
||||||
VER="",
|
VER="",
|
||||||
|
reload_callback=_http_reload_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -422,7 +422,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
pickfile = config.get("pickfile", "hbd.pickle")
|
pickfile = config.get("pickfile", "hbd.pickle")
|
||||||
dyndnshosts = config_mod.get_dyndnshosts(config)
|
dyndnshosts = config_mod.get_dyndnshosts(config)
|
||||||
watchhosts = config_mod.get_watchhosts(config)
|
watchhosts = config_mod.get_watchhosts(config)
|
||||||
drophosts = config.get("drophosts", [])
|
|
||||||
if 1 and os.path.exists(pickfile):
|
if 1 and os.path.exists(pickfile):
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("opening pickls %s", pickfile)
|
logger.info("opening pickls %s", pickfile)
|
||||||
@@ -448,9 +447,6 @@ def load_pickled_hosts(config, hbdclass):
|
|||||||
hbdclass.Host.hosts[h].apply_access(
|
hbdclass.Host.hosts[h].apply_access(
|
||||||
access["owner"], access["managers"], access["monitors"]
|
access["owner"], access["managers"], access["monitors"]
|
||||||
)
|
)
|
||||||
for h in drophosts:
|
|
||||||
if h in hbdclass.Host.hosts:
|
|
||||||
del hbdclass.Host.hosts[h]
|
|
||||||
if config.get("verbose", False):
|
if config.get("verbose", False):
|
||||||
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -140,7 +140,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:
|
||||||
@@ -366,6 +368,9 @@ _TIMEOUT = 15 # seconds per channel send
|
|||||||
|
|
||||||
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||||
"""Send *notif* to a single named channel, honouring min_level."""
|
"""Send *notif* to a single named channel, honouring min_level."""
|
||||||
|
# Strip ownership metadata — notifier drivers only need delivery credentials.
|
||||||
|
channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")}
|
||||||
|
|
||||||
level = notif.level.upper()
|
level = notif.level.upper()
|
||||||
if level != "RECOVER":
|
if level != "RECOVER":
|
||||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||||
@@ -401,7 +406,7 @@ def _build_url(host_name: str) -> str:
|
|||||||
base_url = _config.get("base_url", "").rstrip("/")
|
base_url = _config.get("base_url", "").rstrip("/")
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return ""
|
return ""
|
||||||
return f"{base_url}/plugins#{host_name}"
|
return f"{base_url}/alerts?filter={host_name}"
|
||||||
|
|
||||||
|
|
||||||
async def send_notification(host_name: str, notif: Notification) -> dict:
|
async def send_notification(host_name: str, notif: Notification) -> dict:
|
||||||
|
|||||||
+156
-44
@@ -1,23 +1,37 @@
|
|||||||
"""Gitea OAuth2 support.
|
"""OAuth2 provider support.
|
||||||
|
|
||||||
Config shape (in ~/.hb.yaml):
|
Config shape (in ~/.hb.yaml):
|
||||||
|
|
||||||
oauth:
|
oauth:
|
||||||
gitea:
|
my-gitea: # route slug → /login/oauth/my-gitea
|
||||||
url: https://git.example.com
|
type: gitea # "gitea" | "github" | "nextcloud"
|
||||||
|
# omit type to default to "gitea"
|
||||||
|
url: https://git.example.com # required for gitea and nextcloud
|
||||||
|
client_id: <client-id>
|
||||||
|
client_secret: <client-secret>
|
||||||
|
label: "Work Gitea" # optional display name on login button
|
||||||
|
logo: https://example.com/logo.png # optional logo URL
|
||||||
|
|
||||||
|
github:
|
||||||
|
type: github
|
||||||
client_id: <client-id>
|
client_id: <client-id>
|
||||||
client_secret: <client-secret>
|
client_secret: <client-secret>
|
||||||
|
|
||||||
Register a Gitea OAuth2 application at:
|
nextcloud:
|
||||||
Gitea → Settings → Applications → OAuth2
|
type: nextcloud
|
||||||
Set the redirect URI to:
|
url: https://cloud.example.com
|
||||||
https://<hbd-host>/login/oauth/gitea/callback
|
client_id: <client-id>
|
||||||
|
client_secret: <client-secret>
|
||||||
|
|
||||||
|
Register the OAuth app with each provider and set the redirect URI to:
|
||||||
|
https://<hbd-host>/login/oauth/<name>/callback
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -57,44 +71,129 @@ class OAuthError(Exception):
|
|||||||
"""Raised when the OAuth2 flow fails for any reason."""
|
"""Raised when the OAuth2 flow fails for any reason."""
|
||||||
|
|
||||||
|
|
||||||
def _gitea_cfg(config: dict) -> dict:
|
PROVIDER_DEFS: dict = {
|
||||||
"""Return the gitea sub-dict or {} if absent/incomplete."""
|
"gitea": {
|
||||||
return config.get("oauth", {}).get("gitea", {})
|
"authorize_url_tmpl": "{url}/login/oauth/authorize",
|
||||||
|
"token_url_tmpl": "{url}/login/oauth/access_token",
|
||||||
|
"profile_url_tmpl": "{url}/api/v1/user",
|
||||||
|
"scope": "user:email",
|
||||||
|
"field_map": {"username": "login", "full_name": "full_name", "avatar": "avatar_url"},
|
||||||
|
"profile_data_path": [],
|
||||||
|
"requires_url": True,
|
||||||
|
"default_label": "Gitea",
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"authorize_url_tmpl": "https://github.com/login/oauth/authorize",
|
||||||
|
"token_url_tmpl": "https://github.com/login/oauth/access_token",
|
||||||
|
"profile_url_tmpl": "https://api.github.com/user",
|
||||||
|
"scope": "read:user",
|
||||||
|
"field_map": {"username": "login", "full_name": "name", "avatar": "avatar_url"},
|
||||||
|
"profile_data_path": [],
|
||||||
|
"requires_url": False,
|
||||||
|
"default_label": "GitHub",
|
||||||
|
},
|
||||||
|
"nextcloud": {
|
||||||
|
"authorize_url_tmpl": "{url}/apps/oauth2/authorize",
|
||||||
|
"token_url_tmpl": "{url}/apps/oauth2/api/v1/token",
|
||||||
|
"profile_url_tmpl": "{url}/ocs/v2.php/cloud/user?format=json",
|
||||||
|
"scope": "",
|
||||||
|
"field_map": {"username": "id", "full_name": "display-name", "avatar": None},
|
||||||
|
"profile_data_path": ["ocs", "data"],
|
||||||
|
"requires_url": True,
|
||||||
|
"default_label": "Nextcloud",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProvider:
|
||||||
|
"""A fully resolved OAuth2 provider instance, ready to use."""
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
label: str
|
||||||
|
logo: str
|
||||||
|
authorize_url: str
|
||||||
|
token_url: str
|
||||||
|
profile_url: str
|
||||||
|
scope: str
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
field_map: dict
|
||||||
|
profile_data_path: list
|
||||||
|
|
||||||
|
|
||||||
|
def get_providers(config: dict) -> list[ResolvedProvider]:
|
||||||
|
"""Return a ResolvedProvider for every valid entry in config['oauth'].
|
||||||
|
|
||||||
|
Entries with missing required fields or unknown types are skipped with
|
||||||
|
a warning log. Order follows config declaration order.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
oauth_cfg = config.get("oauth", {})
|
||||||
|
if not isinstance(oauth_cfg, dict):
|
||||||
|
return result
|
||||||
|
for name, entry in oauth_cfg.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
provider_type = entry.get("type", "gitea")
|
||||||
|
defn = PROVIDER_DEFS.get(provider_type)
|
||||||
|
if defn is None:
|
||||||
|
logger.warning("OAuth: unknown provider type %r for %r, skipping", provider_type, name)
|
||||||
|
continue
|
||||||
|
client_id = entry.get("client_id", "")
|
||||||
|
client_secret = entry.get("client_secret", "")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
logger.warning("OAuth: %r missing client_id or client_secret, skipping", name)
|
||||||
|
continue
|
||||||
|
url = entry.get("url", "").rstrip("/")
|
||||||
|
if defn["requires_url"] and not url:
|
||||||
|
logger.warning("OAuth: %r requires url but none configured, skipping", name)
|
||||||
|
continue
|
||||||
|
label = entry.get("label") or defn["default_label"]
|
||||||
|
logo = entry.get("logo", "")
|
||||||
|
result.append(ResolvedProvider(
|
||||||
|
name=name,
|
||||||
|
type=provider_type,
|
||||||
|
label=label,
|
||||||
|
logo=logo,
|
||||||
|
authorize_url=defn["authorize_url_tmpl"].format(url=url),
|
||||||
|
token_url=defn["token_url_tmpl"].format(url=url),
|
||||||
|
profile_url=defn["profile_url_tmpl"].format(url=url),
|
||||||
|
scope=defn["scope"],
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
field_map=dict(defn["field_map"]),
|
||||||
|
profile_data_path=list(defn["profile_data_path"]),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(config: dict) -> bool:
|
def is_enabled(config: dict) -> bool:
|
||||||
"""Return True when all three required Gitea OAuth keys are present."""
|
"""Return True when at least one OAuth provider is fully configured."""
|
||||||
g = _gitea_cfg(config)
|
return bool(get_providers(config))
|
||||||
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
|
|
||||||
|
|
||||||
|
|
||||||
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
|
def build_auth_url(provider: ResolvedProvider, state: str, redirect_uri: str) -> str:
|
||||||
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
|
"""Return the provider's OAuth2 authorization URL to redirect the browser to."""
|
||||||
g = _gitea_cfg(config)
|
params: dict = {
|
||||||
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
|
"client_id": provider.client_id,
|
||||||
raise OAuthError("Gitea OAuth2 is not configured")
|
|
||||||
params = urllib.parse.urlencode({
|
|
||||||
"client_id": g["client_id"],
|
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "user:email",
|
|
||||||
"state": state,
|
"state": state,
|
||||||
})
|
}
|
||||||
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
|
if provider.scope:
|
||||||
|
params["scope"] = provider.scope
|
||||||
|
return f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
async def exchange_code(provider: ResolvedProvider, code: str, redirect_uri: str) -> str:
|
||||||
"""Exchange an authorization *code* for a Gitea access token.
|
"""Exchange an authorization *code* for an access token.
|
||||||
|
|
||||||
Returns the access token string. Raises OAuthError on any failure.
|
Returns the access token string. Raises OAuthError on any failure.
|
||||||
"""
|
"""
|
||||||
g = _gitea_cfg(config)
|
|
||||||
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
|
|
||||||
raise OAuthError("Gitea OAuth2 is not configured")
|
|
||||||
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
|
|
||||||
payload = {
|
payload = {
|
||||||
"client_id": g["client_id"],
|
"client_id": provider.client_id,
|
||||||
"client_secret": g["client_secret"],
|
"client_secret": provider.client_secret,
|
||||||
"code": code,
|
"code": code,
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
@@ -102,7 +201,11 @@ async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
|||||||
timeout = aiohttp.ClientTimeout(total=10)
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
|
async with session.post(
|
||||||
|
provider.token_url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
||||||
@@ -115,28 +218,37 @@ async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
async def fetch_user(config: dict, token: str) -> dict:
|
async def fetch_user(provider: ResolvedProvider, token: str) -> dict:
|
||||||
"""Fetch the authenticated user's profile from Gitea.
|
"""Fetch the authenticated user's profile from the provider.
|
||||||
|
|
||||||
Returns a dict with keys: login, full_name, avatar_url.
|
Returns a dict with keys: login, full_name, avatar_url.
|
||||||
Raises OAuthError on any failure.
|
Raises OAuthError on any failure.
|
||||||
"""
|
"""
|
||||||
g = _gitea_cfg(config)
|
|
||||||
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
|
|
||||||
raise OAuthError("Gitea OAuth2 is not configured")
|
|
||||||
url = f"{g['url'].rstrip('/')}/api/v1/user"
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
|
async with session.get(
|
||||||
|
provider.profile_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
except aiohttp.ClientError as exc:
|
except aiohttp.ClientError as exc:
|
||||||
raise OAuthError(f"User fetch network error: {exc}") from exc
|
raise OAuthError(f"User fetch network error: {exc}") from exc
|
||||||
return {
|
|
||||||
"login": data.get("login", ""),
|
try:
|
||||||
"full_name": data.get("full_name", ""),
|
for key in provider.profile_data_path:
|
||||||
"avatar_url": data.get("avatar_url", ""),
|
data = data.get(key, {})
|
||||||
}
|
avatar_field = provider.field_map.get("avatar")
|
||||||
|
return {
|
||||||
|
"login": data.get(provider.field_map["username"], ""),
|
||||||
|
"full_name": data.get(provider.field_map["full_name"], ""),
|
||||||
|
"avatar_url": data.get(avatar_field, "") if avatar_field else "",
|
||||||
|
}
|
||||||
|
except AttributeError:
|
||||||
|
raise OAuthError(f"Unexpected profile response structure from {provider.type}")
|
||||||
|
|||||||
+157
-32
@@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({
|
|||||||
"smtp_password", "smtp_user", "api_password", "access_token",
|
"smtp_password", "smtp_user", "api_password", "access_token",
|
||||||
})
|
})
|
||||||
|
|
||||||
_CHANNEL_TYPE_LABELS = {
|
CHANNEL_TYPE_SCHEMAS = {
|
||||||
"pushover": "Pushover",
|
"pushover": {
|
||||||
"email": "E-mail",
|
"label": "Pushover",
|
||||||
"signal": "Signal",
|
"fields": [
|
||||||
"mattermost": "Mattermost",
|
{"key": "token", "label": "App token", "type": "secret", "required": True},
|
||||||
|
{"key": "user", "label": "User key", "type": "secret", "required": True},
|
||||||
|
{"key": "sound", "label": "Sound", "type": "text", "required": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "E-mail",
|
||||||
|
"fields": [
|
||||||
|
{"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True},
|
||||||
|
{"key": "sender", "label": "From address", "type": "text", "required": True},
|
||||||
|
{"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True},
|
||||||
|
{"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False},
|
||||||
|
{"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False},
|
||||||
|
{"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"signal": {
|
||||||
|
"label": "Signal",
|
||||||
|
"fields": [
|
||||||
|
{"key": "user", "label": "Sender number", "type": "text", "required": True},
|
||||||
|
{"key": "recipient", "label": "Recipient number", "type": "text", "required": True},
|
||||||
|
{"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"matrix": {
|
||||||
|
"label": "Matrix",
|
||||||
|
"fields": [
|
||||||
|
{"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True},
|
||||||
|
{"key": "access_token", "label": "Access token", "type": "secret", "required": True},
|
||||||
|
{"key": "room_id", "label": "Room ID", "type": "text", "required": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sms_voipms": {
|
||||||
|
"label": "SMS (voip.ms)",
|
||||||
|
"fields": [
|
||||||
|
{"key": "api_user", "label": "API username", "type": "text", "required": True},
|
||||||
|
{"key": "api_password", "label": "API password", "type": "secret", "required": True},
|
||||||
|
{"key": "did", "label": "DID (from)", "type": "text", "required": True},
|
||||||
|
{"key": "dst", "label": "Destination", "type": "text", "required": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mattermost": {
|
||||||
|
"label": "Mattermost",
|
||||||
|
"fields": [
|
||||||
|
{"key": "host", "label": "Host", "type": "text", "required": True},
|
||||||
|
{"key": "token", "label": "Webhook token", "type": "secret", "required": True},
|
||||||
|
{"key": "channel", "label": "Channel", "type": "text", "required": True},
|
||||||
|
{"key": "username", "label": "Bot username", "type": "text", "required": False},
|
||||||
|
{"key": "icon", "label": "Icon URL", "type": "text", "required": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()}
|
||||||
|
|
||||||
|
|
||||||
def _mask(value):
|
def _mask(value):
|
||||||
"""Return a masked placeholder for sensitive values."""
|
"""Return a masked placeholder for sensitive values."""
|
||||||
@@ -143,14 +195,15 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- Notification channels (complex, built separately) ----------------
|
# ---- Notification channels (complex, built separately) ----------------
|
||||||
|
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
|
||||||
notif_channels = []
|
notif_channels = []
|
||||||
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
|
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
|
||||||
if not isinstance(ch_cfg, dict):
|
if not isinstance(ch_cfg, dict):
|
||||||
continue
|
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():
|
||||||
if k == "type":
|
if k in _METADATA_KEYS:
|
||||||
continue
|
continue
|
||||||
sensitive = k in _SECRET_KEYS
|
sensitive = k in _SECRET_KEYS
|
||||||
fields.append({
|
fields.append({
|
||||||
@@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"name": ch_name,
|
"name": ch_name,
|
||||||
"type": ch_type,
|
"type": ch_type,
|
||||||
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
|
"owner": ch_cfg.get("owner"),
|
||||||
|
"private": bool(ch_cfg.get("private", False)),
|
||||||
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,6 +247,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"hysteresis": tc.hysteresis,
|
"hysteresis": tc.hysteresis,
|
||||||
"count": tc.count,
|
"count": tc.count,
|
||||||
"enabled": tc.enabled,
|
"enabled": tc.enabled,
|
||||||
|
"display": tc.display or "",
|
||||||
|
"grace": tc.grace,
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold_config_list = []
|
threshold_config_list = []
|
||||||
@@ -218,7 +276,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
|
|
||||||
# ---- 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
|
||||||
hosts_list.append({
|
hosts_list.append({
|
||||||
@@ -228,32 +286,55 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"owner": hcfg.get("owner", ""),
|
"owner": hcfg.get("owner", ""),
|
||||||
"managers": hcfg.get("managers", []),
|
"managers": hcfg.get("managers", []),
|
||||||
"monitors": hcfg.get("monitors", []),
|
"monitors": hcfg.get("monitors", []),
|
||||||
"threshold_config": hcfg.get("threshold_config", ""),
|
"threshold_configs": (
|
||||||
|
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
||||||
|
else ([v] if v else [])
|
||||||
|
),
|
||||||
"notification_channels": hcfg.get("notification_channels", []),
|
"notification_channels": hcfg.get("notification_channels", []),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---- OAuth providers -------------------------------------------------------
|
||||||
|
oauth_providers = []
|
||||||
|
for pname, pattrs in (config.get("oauth") or {}).items():
|
||||||
|
if not isinstance(pattrs, dict):
|
||||||
|
continue
|
||||||
|
cs = pattrs.get("client_secret", "")
|
||||||
|
oauth_providers.append({
|
||||||
|
"name": pname,
|
||||||
|
"type": pattrs.get("type", "gitea"),
|
||||||
|
"url": pattrs.get("url", ""),
|
||||||
|
"client_id": pattrs.get("client_id", ""),
|
||||||
|
"client_secret": "•••" if cs else "",
|
||||||
|
"label": pattrs.get("label", ""),
|
||||||
|
"logo": pattrs.get("logo", ""),
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": "network",
|
"id": "network",
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
"description": "Ports and bind addresses for all server sockets.",
|
"description": "Ports and bind addresses for all server sockets.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
"fields": [
|
"fields": [
|
||||||
field("hb_port", "Heartbeat UDP port", "port",
|
field("hb_port", "Heartbeat UDP port", "port",
|
||||||
"UDP port the server listens on for heartbeat datagrams."),
|
"UDP port the server listens on for heartbeat datagrams.", editable=True),
|
||||||
field("hbd_host", "HTTP bind address", "text",
|
field("hbd_host", "HTTP bind address", "text",
|
||||||
"Interface to bind the HTTP server to. Empty = all interfaces."),
|
"Interface to bind the HTTP server to. Empty = all interfaces.", editable=True),
|
||||||
field("hbd_port", "HTTP API port", "port",
|
field("hbd_port", "HTTP API port", "port",
|
||||||
"TCP port for the HTTP API and web UI."),
|
"TCP port for the HTTP API and web UI.", editable=True),
|
||||||
field("ws_port", "WebSocket port", "port",
|
field("ws_port", "WebSocket port", "port",
|
||||||
"TCP port for the plain WebSocket server."),
|
"TCP port for the plain WebSocket server.", editable=True),
|
||||||
field("wss_port", "Secure WebSocket port", "port",
|
field("wss_port", "Secure WebSocket port", "port",
|
||||||
"TCP port for WSS (TLS WebSocket). Leave empty to disable."),
|
"TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tls",
|
"id": "tls",
|
||||||
"title": "TLS / WebSocket Security",
|
"title": "TLS / WebSocket Security",
|
||||||
"description": "Certificate paths used when wss_port is set.",
|
"description": "Certificate paths used when wss_port is set.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": None,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("cert_path", "Certificate directory", "path",
|
field("cert_path", "Certificate directory", "path",
|
||||||
"Directory containing the TLS certificate and key files."),
|
"Directory containing the TLS certificate and key files."),
|
||||||
@@ -267,73 +348,97 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "monitoring",
|
"id": "monitoring",
|
||||||
"title": "Monitoring",
|
"title": "Monitoring",
|
||||||
"description": "Heartbeat timing and alert re-notification behaviour.",
|
"description": "Heartbeat timing and alert re-notification behaviour.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
"fields": [
|
"fields": [
|
||||||
field("interval", "Heartbeat interval", "duration",
|
field("interval", "Heartbeat interval", "duration",
|
||||||
"Expected time between heartbeat messages from each client."),
|
"Expected time between heartbeat messages from each client.", editable=True),
|
||||||
field("grace", "Grace multiplier", "number",
|
field("grace", "Grace period", "number",
|
||||||
"A host is marked overdue after interval × grace seconds of silence."),
|
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
|
||||||
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
||||||
"How often to re-send notifications for ongoing threshold alerts."),
|
"How often to re-send notifications for ongoing threshold alerts.", editable=True),
|
||||||
field("autosave_interval", "Autosave interval", "duration",
|
field("autosave_interval", "Autosave interval", "duration",
|
||||||
"How often the server saves its state to disk."),
|
"How often the server saves its state to disk."),
|
||||||
|
field("base_url", "Base URL", "text",
|
||||||
|
"Base URL for notification links.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "persistence",
|
"id": "persistence",
|
||||||
"title": "Persistence & Logging",
|
"title": "Persistence & Logging",
|
||||||
"description": "State file and event log settings.",
|
"description": "State file and event log settings.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
"fields": [
|
"fields": [
|
||||||
field("pickfile", "State file", "path",
|
field("pickfile", "State file", "path",
|
||||||
"Path to the pickle file used to persist host state across restarts."),
|
"Path to the pickle file used to persist host state across restarts.", editable=True),
|
||||||
field("logfile", "Event log", "path",
|
field("logfile", "Event log", "path",
|
||||||
"Path to the event log file."),
|
"Path to the event log file.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "journal",
|
"id": "journal",
|
||||||
"title": "Message Journal",
|
"title": "Message Journal",
|
||||||
"description": "All received heartbeat and plugin messages are journalled here.",
|
"description": "All received heartbeat and plugin messages are journalled here.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "server",
|
||||||
"fields": [
|
"fields": [
|
||||||
field("journal_enabled", "Enabled", "boolean",
|
field("journal_enabled", "Enabled", "boolean",
|
||||||
"Turn journalling on or off."),
|
"Turn journalling on or off.", editable=True),
|
||||||
field("journal_dir", "Journal directory","path",
|
field("journal_dir", "Journal directory","path",
|
||||||
"Directory where journal files are written."),
|
"Directory where journal files are written.", editable=True),
|
||||||
field("journal_file", "Journal filename", "text",
|
field("journal_file", "Journal filename", "text",
|
||||||
"Base filename for the journal (rotated copies get a numeric suffix)."),
|
"Base filename for the journal (rotated copies get a numeric suffix)."),
|
||||||
field("journal_max_size", "Max file size", "size",
|
field("journal_max_size", "Max file size", "size",
|
||||||
"Rotate the journal when it exceeds this size."),
|
"Rotate the journal when it exceeds this size.", editable=True),
|
||||||
field("journal_max_backups", "Backup count", "number",
|
field("journal_max_backups", "Backup count", "number",
|
||||||
"Number of rotated journal files to keep."),
|
"Number of rotated journal files to keep.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dns",
|
"id": "dns",
|
||||||
"title": "Dynamic DNS",
|
"title": "Dynamic DNS",
|
||||||
"description": "nsupdate-based DNS registration for dynamic hosts.",
|
"description": "nsupdate-based DNS registration via nsupdate(8).",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "dns",
|
||||||
"fields": [
|
"fields": [
|
||||||
field("nsupdate_bin", "nsupdate binary", "path",
|
field("nsupdate_bin", "nsupdate binary", "path",
|
||||||
"Full path to the nsupdate executable."),
|
"Path to the nsupdate binary.", editable=True),
|
||||||
field("dyndomains", "Dynamic domains", "list",
|
field("rndc_key", "RNDC key file", "path",
|
||||||
"DNS zones managed by nsupdate for dynamic hosts."),
|
"Path to the rndc key file used to authenticate DNS updates.", editable=True),
|
||||||
field("drophosts", "Drop hosts", "list",
|
field("dyndomains", "Dynamic domains", "list",
|
||||||
"Hostnames to silently ignore — no state, no alerts."),
|
"Domains updated via nsupdate when a host with dyndns: true reports in.",
|
||||||
|
editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "users",
|
"id": "users",
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"description": "Accounts defined in the config file. Password hashes are never shown.",
|
"description": "Accounts defined in the config file. Password hashes are never shown.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "users",
|
||||||
"users": users_list,
|
"users": users_list,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_owner", "Default owner", "text",
|
field("default_owner", "Default owner", "text",
|
||||||
"Username that owns hosts with no explicit owner. "
|
"Username that owns hosts with no explicit owner. "
|
||||||
"Falls back to the first admin user."),
|
"Falls back to the first admin user.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "oauth",
|
||||||
|
"title": "OAuth Providers",
|
||||||
|
"description": "OAuth2 login providers. Client secrets are masked.",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": "oauth",
|
||||||
|
"providers": oauth_providers,
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "channels",
|
"id": "channels",
|
||||||
"title": "Notification Channels",
|
"title": "Notification Channels",
|
||||||
"description": "Named notification providers. Credentials are masked.",
|
"description": "Named notification providers. Credentials are masked.",
|
||||||
|
"section_mode": "channels",
|
||||||
|
"api_section": "notification_channels",
|
||||||
"channels": notif_channels,
|
"channels": notif_channels,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_notification_channels", "Default channels", "list",
|
field("default_notification_channels", "Default channels", "list",
|
||||||
@@ -344,6 +449,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "hosts",
|
"id": "hosts",
|
||||||
"title": "Hosts",
|
"title": "Hosts",
|
||||||
"description": "Host definitions loaded from the config file.",
|
"description": "Host definitions loaded from the config file.",
|
||||||
|
"section_mode": "hosts",
|
||||||
|
"api_section": "hosts",
|
||||||
"hosts": hosts_list,
|
"hosts": hosts_list,
|
||||||
"fields": [],
|
"fields": [],
|
||||||
},
|
},
|
||||||
@@ -351,16 +458,20 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "thresholds",
|
"id": "thresholds",
|
||||||
"title": "Threshold Configurations",
|
"title": "Threshold Configurations",
|
||||||
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||||
|
"section_mode": "thresholds",
|
||||||
|
"api_section": "thresholds",
|
||||||
"threshold_configs": threshold_config_list,
|
"threshold_configs": threshold_config_list,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_threshold_config", "Default config", "text",
|
field("default_threshold_config", "Default config", "text",
|
||||||
"Threshold config used for hosts with no explicit mapping."),
|
"Threshold config used for hosts with no explicit mapping.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
"title": "Runtime",
|
"title": "Runtime",
|
||||||
"description": "Flags set at startup (require restart to change).",
|
"description": "Flags set at startup (require restart to change).",
|
||||||
|
"section_mode": "form",
|
||||||
|
"api_section": None,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("foreground", "Foreground mode", "boolean",
|
field("foreground", "Foreground mode", "boolean",
|
||||||
"Run in the foreground instead of daemonising."),
|
"Run in the foreground instead of daemonising."),
|
||||||
@@ -371,3 +482,17 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
||||||
|
"""Return sections list + auxiliary data for the settings template."""
|
||||||
|
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
||||||
|
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
||||||
|
all_usernames = sorted((config.get("users") or {}).keys())
|
||||||
|
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
|
||||||
|
return {
|
||||||
|
"sections": sections,
|
||||||
|
"all_channel_names": all_channel_names,
|
||||||
|
"all_usernames": all_usernames,
|
||||||
|
"all_threshold_configs": all_threshold_configs,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -163,7 +176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Email</span>
|
<span class="info-label">Email</span>
|
||||||
<span class="info-value"><a href="mailto:aew@wrede.ca">aew@wrede.ca</a></span>
|
<span class="info-value"><a href="mailto:aew.hbd@wrede.ca">aew.hbd@wrede.ca</a></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Repository</span>
|
<span class="info-label">Repository</span>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
@@ -94,6 +94,24 @@
|
|||||||
border-color: #2196f3;
|
border-color: #2196f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input.invalid {
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
.alerts-container {
|
.alerts-container {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -203,7 +221,7 @@
|
|||||||
|
|
||||||
.alert-duration {
|
.alert-duration {
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-actions {
|
.alert-actions {
|
||||||
@@ -220,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;
|
||||||
}
|
}
|
||||||
@@ -275,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;
|
||||||
@@ -287,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>
|
||||||
@@ -316,6 +359,7 @@
|
|||||||
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
||||||
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
||||||
|
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alerts-container">
|
<div class="alerts-container">
|
||||||
@@ -332,6 +376,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let allAlerts = [];
|
let allAlerts = [];
|
||||||
|
let hostFilterRe = null;
|
||||||
|
|
||||||
async function loadAlerts() {
|
async function loadAlerts() {
|
||||||
try {
|
try {
|
||||||
@@ -366,10 +411,13 @@
|
|||||||
// Filter alerts based on current filter
|
// Filter alerts based on current filter
|
||||||
let filteredAlerts = alerts;
|
let filteredAlerts = alerts;
|
||||||
if (currentFilter !== 'all') {
|
if (currentFilter !== 'all') {
|
||||||
filteredAlerts = alerts.filter(alert =>
|
filteredAlerts = filteredAlerts.filter(alert =>
|
||||||
alert.level.toLowerCase() === currentFilter
|
alert.level.toLowerCase() === currentFilter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hostFilterRe) {
|
||||||
|
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
|
||||||
|
}
|
||||||
|
|
||||||
if (filteredAlerts.length === 0) {
|
if (filteredAlerts.length === 0) {
|
||||||
if (currentFilter === 'all' && alerts.length === 0) {
|
if (currentFilter === 'all' && alerts.length === 0) {
|
||||||
@@ -538,9 +586,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onHostFilterInput(input) {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val) {
|
||||||
|
hostFilterRe = null;
|
||||||
|
input.classList.remove('invalid');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
hostFilterRe = new RegExp(val, 'i');
|
||||||
|
input.classList.remove('invalid');
|
||||||
|
} catch (_) {
|
||||||
|
hostFilterRe = null;
|
||||||
|
input.classList.add('invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderAlerts(allAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh every 15 seconds
|
// Auto-refresh every 15 seconds
|
||||||
setInterval(loadAlerts, 15000);
|
setInterval(loadAlerts, 15000);
|
||||||
|
|
||||||
|
// Initialise filter from URL query string (?filter=...)
|
||||||
|
(function () {
|
||||||
|
const param = new URLSearchParams(window.location.search).get('filter');
|
||||||
|
if (param) {
|
||||||
|
const input = document.getElementById('host-filter');
|
||||||
|
input.value = param;
|
||||||
|
onHostFilterInput(input);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<div id="copyright">
|
<div id="copyright">
|
||||||
©2002-2026 <A HREF="mailto:andreas@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p>
|
©2002-2026 <A HREF="mailto:aew.hbd@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
+111
-12
@@ -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,39 @@
|
|||||||
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 */
|
||||||
|
.nav-publish-btn {
|
||||||
|
background: #e65100;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.nav-publish-btn:hover { background: #bf360c; }
|
||||||
|
.nav-publish-btn:disabled { opacity: 0.7; cursor: default; }
|
||||||
|
|
||||||
/* Swiss railway clock — nav */
|
/* Swiss railway clock — nav */
|
||||||
.nav-pie {
|
.nav-pie {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -262,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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +201,43 @@
|
|||||||
.log-recover .log-level { color: #2a7a2a; }
|
.log-recover .log-level { color: #2a7a2a; }
|
||||||
.log-info .log-level { color: #555; }
|
.log-info .log-level { color: #555; }
|
||||||
|
|
||||||
|
.log-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-section-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"],
|
||||||
|
.log-filter-bar select {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1.00em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-filter-bar input[type="text"] { width: 110px; }
|
||||||
|
|
||||||
/* Modal for connection status messages */
|
/* Modal for connection status messages */
|
||||||
.connection-modal {
|
.connection-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -251,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;
|
||||||
@@ -445,6 +507,22 @@
|
|||||||
updateRowAlert(name_idx[data.name], data);
|
updateRowAlert(name_idx[data.name], data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLogFilters() {
|
||||||
|
var hostFilter = document.getElementById('filter-host').value.toLowerCase().trim();
|
||||||
|
var levelFilter = document.getElementById('filter-level').value;
|
||||||
|
var msgFilter = document.getElementById('filter-msg').value.toLowerCase().trim();
|
||||||
|
document.querySelectorAll('#messages .log-entry').forEach(function(entry) {
|
||||||
|
var show = true;
|
||||||
|
if (hostFilter && !(entry.dataset.host || '').toLowerCase().includes(hostFilter)) show = false;
|
||||||
|
if (levelFilter && entry.dataset.level !== levelFilter) show = false;
|
||||||
|
if (msgFilter) {
|
||||||
|
var msgEl = entry.querySelector('.log-msg');
|
||||||
|
if (!msgEl || !msgEl.textContent.toLowerCase().includes(msgFilter)) show = false;
|
||||||
|
}
|
||||||
|
entry.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function WS_Connect() {
|
function WS_Connect() {
|
||||||
if ("WebSocket" in window) {
|
if ("WebSocket" in window) {
|
||||||
//N.B: subprotocol field causes chrome to error 1006
|
//N.B: subprotocol field causes chrome to error 1006
|
||||||
@@ -479,14 +557,16 @@
|
|||||||
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
||||||
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||||
var lvl = (msg.level || "INFO").toLowerCase();
|
var lvl = (msg.level || "INFO").toLowerCase();
|
||||||
var html = '<div class="log-entry log-' + lvl + '">';
|
var hostVal = msg.host || '';
|
||||||
|
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '"') + '">';
|
||||||
html += '<span class="log-ts">' + ts_str + '</span>';
|
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||||
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
||||||
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
||||||
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
||||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
msgs.insertAdjacentHTML("afterbegin", html);
|
msgs.insertAdjacentHTML(state.history ? "beforeend" : "afterbegin", html);
|
||||||
|
applyLogFilters();
|
||||||
}
|
}
|
||||||
cnt++;
|
cnt++;
|
||||||
};
|
};
|
||||||
@@ -575,7 +655,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-section">
|
<div class="log-section">
|
||||||
<h2>Log of Events</h2>
|
<div class="log-section-header">
|
||||||
|
<span class="log-section-title">Log of Events</span>
|
||||||
|
<div class="log-filter-bar">
|
||||||
|
<input type="text" id="filter-host" placeholder="Host…" title="Filter by host" />
|
||||||
|
<select id="filter-level" title="Filter by level">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="info">INFO</option>
|
||||||
|
<option value="warning">WARNING</option>
|
||||||
|
<option value="critical">CRITICAL</option>
|
||||||
|
<option value="recover">RECOVER</option>
|
||||||
|
<option value="unknown">UNKNOWN</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,6 +685,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
setup();
|
setup();
|
||||||
|
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
|
||||||
|
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
|
||||||
|
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if current_user and current_user.admin %}
|
||||||
|
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">⚠ Publish Config</button>
|
||||||
|
{% endif %}
|
||||||
<div class="nav-pie" title="Host alert status">
|
<div class="nav-pie" title="Host alert status">
|
||||||
<canvas id="alert-pie" width="44" height="44"></canvas>
|
<canvas id="alert-pie" width="44" height="44"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,5 +95,40 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateAlertPie();
|
updateAlertPie();
|
||||||
setInterval(updateAlertPie, 30000);
|
setInterval(updateAlertPie, 30000);
|
||||||
|
navCheckPendingConfig();
|
||||||
|
window.addEventListener('storage', navCheckPendingConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function navCheckPendingConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.style.display = localStorage.getItem('hbd_pending_config') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navPublishConfig() {
|
||||||
|
var btn = document.getElementById('nav-publish-btn');
|
||||||
|
var pending = localStorage.getItem('hbd_pending_config');
|
||||||
|
if (!pending) return;
|
||||||
|
var staged;
|
||||||
|
try { staged = JSON.parse(pending); } catch(e) { return; }
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/0/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: pending
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
var err = await resp.json().catch(function() { return {}; });
|
||||||
|
alert('Error: ' + (err.error || resp.statusText));
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Network error: ' + e.message);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────── */
|
||||||
@@ -388,6 +388,71 @@
|
|||||||
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
||||||
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
||||||
|
|
||||||
|
/* ── Host info section ──────────────────────────────────────────────────── */
|
||||||
|
.host-info-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
font-size: 1.00em;
|
||||||
|
}
|
||||||
|
.info-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 3px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
||||||
|
.info-value { color: #222; }
|
||||||
|
.info-thresholds-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.info-note { color: #888; font-style: italic; }
|
||||||
|
.info-loading { color: #bbb; font-style: italic; }
|
||||||
|
.threshold-covers { font-size: 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>
|
||||||
@@ -436,6 +501,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-body">
|
<div class="host-body">
|
||||||
|
<div class="host-info-section" id="info-{{ host.name }}">
|
||||||
|
<div class="info-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
||||||
{% for plugin in plugin_order if plugin in host.plugins %}
|
{% for plugin in plugin_order if plugin in host.plugins %}
|
||||||
<div class="plugin-accordion collapsed"
|
<div class="plugin-accordion collapsed"
|
||||||
@@ -488,6 +556,9 @@
|
|||||||
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
||||||
const pluginCache = {};
|
const pluginCache = {};
|
||||||
|
|
||||||
|
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
||||||
|
const infoCache = {};
|
||||||
|
|
||||||
function setCache(hostname, pluginName, sample) {
|
function setCache(hostname, pluginName, sample) {
|
||||||
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
||||||
pluginCache[hostname][pluginName] = {
|
pluginCache[hostname][pluginName] = {
|
||||||
@@ -521,6 +592,61 @@
|
|||||||
return json.samples?.[0] ?? null;
|
return json.samples?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchHostInfo(hostname) {
|
||||||
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInfoSection(hostname, data) {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const owner = data.owner ? escHtml(data.owner) : '—';
|
||||||
|
const managers = data.managers && data.managers.length
|
||||||
|
? data.managers.map(escHtml).join(', ') : '—';
|
||||||
|
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
||||||
|
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
||||||
|
const lastPkt = data.last_packet != null
|
||||||
|
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
||||||
|
|
||||||
|
let html = `<div class="info-meta">
|
||||||
|
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
||||||
|
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
||||||
|
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
||||||
|
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
||||||
|
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (data.thresholds === null) {
|
||||||
|
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
||||||
|
} else if (data.thresholds.length === 0) {
|
||||||
|
html += `<div class="info-note">No thresholds defined.</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
for (const t of data.thresholds) {
|
||||||
|
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
|
||||||
|
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
|
||||||
|
let metricCell = escHtml(t.metric);
|
||||||
|
if (t.covers && t.covers.length > 0) {
|
||||||
|
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
|
||||||
|
}
|
||||||
|
html += `<tr>
|
||||||
|
<td class="key">${metricCell}</td>
|
||||||
|
<td>${escHtml(t.operator)}</td>
|
||||||
|
<td>${w}</td>
|
||||||
|
<td>${c}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHostGlance(hostname) {
|
async function fetchHostGlance(hostname) {
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
||||||
@@ -644,8 +770,21 @@
|
|||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
const wasCollapsed = card.classList.contains('collapsed');
|
const wasCollapsed = card.classList.contains('collapsed');
|
||||||
card.classList.toggle('collapsed');
|
card.classList.toggle('collapsed');
|
||||||
if (wasCollapsed && !pluginCache[hostname]) {
|
if (wasCollapsed) {
|
||||||
fetchHostGlance(hostname);
|
if (!pluginCache[hostname]) {
|
||||||
|
fetchHostGlance(hostname);
|
||||||
|
}
|
||||||
|
if (!infoCache[hostname]) {
|
||||||
|
const infoEl = document.getElementById(`info-${hostname}`);
|
||||||
|
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,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;
|
||||||
@@ -787,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 ────────────────────────────────────────────────
|
||||||
@@ -794,10 +937,11 @@
|
|||||||
function renderOsInfoTable(d) {
|
function renderOsInfoTable(d) {
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
'processor','architecture','node','python_version',
|
'processor','architecture','node','python_version',
|
||||||
'python_implementation','hbc_version',
|
'python_implementation',
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||||
const shown = new Set(ORDER);
|
const shown = new Set(ORDER);
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||||
|
|
||||||
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
@@ -808,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'],
|
||||||
@@ -826,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];
|
||||||
@@ -1206,9 +1436,12 @@
|
|||||||
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
|
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
document.querySelectorAll('.host-card').forEach(card => {
|
||||||
|
fetchHostGlance(card.dataset.hostname);
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
||||||
const hostname = card.dataset.hostname;
|
const hostname = card.dataset.hostname;
|
||||||
fetchHostGlance(hostname);
|
|
||||||
|
|
||||||
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
||||||
const pname = acc.dataset.plugin;
|
const pname = acc.dataset.plugin;
|
||||||
@@ -1228,24 +1461,39 @@
|
|||||||
// ── Init ────────────────────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// If a host fragment is in the URL, expand and scroll to that host;
|
// Fetch glance data for every host immediately so the strip is always populated.
|
||||||
// otherwise expand the first host as before.
|
document.querySelectorAll('.host-card').forEach(card => {
|
||||||
|
fetchHostGlance(card.dataset.hostname);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand and load info for the target host (URL hash or first host).
|
||||||
|
function expandHost(hostname) {
|
||||||
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
|
if (!card) return false;
|
||||||
|
card.classList.remove('collapsed');
|
||||||
|
fetchHostInfo(hostname).then(data => {
|
||||||
|
infoCache[hostname] = data;
|
||||||
|
renderInfoSection(hostname, data);
|
||||||
|
}).catch(() => {
|
||||||
|
const el = document.getElementById(`info-${hostname}`);
|
||||||
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const hostname = decodeURIComponent(hash.slice(1));
|
const hostname = decodeURIComponent(hash.slice(1));
|
||||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
if (expandHost(hostname)) {
|
||||||
if (card) {
|
setTimeout(() => {
|
||||||
card.classList.remove('collapsed');
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||||
fetchHostGlance(hostname);
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
setTimeout(() => card.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
|
}, 150);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const first = document.querySelector('.host-card');
|
const first = document.querySelector('.host-card');
|
||||||
if (first) {
|
if (first) expandHost(first.dataset.hostname);
|
||||||
first.classList.remove('collapsed');
|
|
||||||
fetchHostGlance(first.dataset.hostname);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// ── Host action helpers ──────────────────────────────────────
|
// ── Host action helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,120 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-name { color: #333; }
|
.channel-name { color: #333; }
|
||||||
|
|
||||||
|
.edit-section { margin-top: 20px; }
|
||||||
|
.edit-section h4 { font-size: .88em; font-weight: 600; color: #333; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #eee; padding-bottom: 6px; }
|
||||||
|
.edit-field { margin-bottom: 10px; }
|
||||||
|
.edit-field label { display: block; font-size: .82em; color: #666; margin-bottom: 3px; }
|
||||||
|
.edit-input { width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; font-size: .88em; box-sizing: border-box; }
|
||||||
|
.edit-input:focus { border-color: #0066cc; outline: none; }
|
||||||
|
.status-msg { font-size: .82em; margin-left: 8px; }
|
||||||
|
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
||||||
|
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
||||||
|
.btn-save:hover { background: #0055aa; }
|
||||||
|
/* ---- Channel chip picker ---- */
|
||||||
|
.ch-picker { }
|
||||||
|
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
|
||||||
|
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
|
||||||
|
.ch-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
|
||||||
|
border: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.ch-chip.selected:hover { background: #bbdefb; }
|
||||||
|
.ch-chip.available { background: #f1f3f4; color: #555; }
|
||||||
|
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
|
||||||
|
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
|
||||||
|
|
||||||
|
/* ---- My Channels card list ---- */
|
||||||
|
.my-ch-card {
|
||||||
|
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.my-ch-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
|
||||||
|
}
|
||||||
|
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
||||||
|
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
|
||||||
|
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
|
||||||
|
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
|
||||||
|
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
|
||||||
|
.btn-sm-edit:hover { background: #666; }
|
||||||
|
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
|
||||||
|
.btn-sm-del:hover { background: #fce4ec; }
|
||||||
|
|
||||||
|
/* ---- 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) ---- */
|
||||||
|
.ch-modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1001;
|
||||||
|
}
|
||||||
|
.ch-modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px;
|
||||||
|
min-width: 360px; max-width: 520px; width: 95%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
|
||||||
|
.ch-form-row { margin-bottom: 12px; }
|
||||||
|
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
|
||||||
|
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
|
||||||
|
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
|
||||||
|
font-size: .88em; box-sizing: border-box; font-family: inherit;
|
||||||
|
}
|
||||||
|
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
|
||||||
|
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
|
||||||
|
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||||
|
.ch-modal-status { font-size: .83em; margin-top: 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -266,19 +380,164 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification channels -->
|
{% if current_user %}
|
||||||
|
<!-- ---- Editable identity ---- -->
|
||||||
|
<div class="section edit-section">
|
||||||
|
<h4>Identity</h4>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-fullname">Display name</label>
|
||||||
|
<input id="profile-fullname" class="edit-input" type="text" value="{{ current_user.full_name | e }}" placeholder="Full name">
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-avatar">Avatar URL or path</label>
|
||||||
|
<input id="profile-avatar" class="edit-input" type="text" value="{{ current_user.avatar | e }}" placeholder="/path/to/avatar.png or https://…">
|
||||||
|
</div>
|
||||||
|
<div class="save-row">
|
||||||
|
<button class="btn-save" onclick="saveIdentity()">Save</button>
|
||||||
|
<span id="identity-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ---- Change password ---- -->
|
||||||
|
<div class="section edit-section">
|
||||||
|
<h4>Change password</h4>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-current-pw">Current password</label>
|
||||||
|
<input id="profile-current-pw" class="edit-input" type="password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label for="profile-new-pw">New password</label>
|
||||||
|
<input id="profile-new-pw" class="edit-input" type="password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="save-row">
|
||||||
|
<button class="btn-save" onclick="changePassword()">Change password</button>
|
||||||
|
<span id="password-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Notification channels — chip picker -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Notification Channels</h2>
|
<h2>Notification Channels</h2>
|
||||||
{% if notification_channels %}
|
{% if current_user %}
|
||||||
{% for ch in notification_channels %}
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
|
||||||
<div class="channel-row">
|
{% if all_channels %}
|
||||||
<span class="channel-type">{{ ch.type }}</span>
|
<div class="ch-picker">
|
||||||
<span class="channel-name">{{ ch.name }}</span>
|
<div class="ch-picker-label">Selected</div>
|
||||||
|
<div id="selected-chips" class="ch-chips">
|
||||||
|
{% for ch in all_channels %}
|
||||||
|
{% if ch.name in (current_user.notification_channels or []) %}
|
||||||
|
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||||||
|
{{ ch.name | e }} <span class="ch-chip-x">×</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set selected_set = current_user.notification_channels or [] %}
|
||||||
|
{% set has_selected = selected_set | length > 0 %}
|
||||||
|
{% if not has_selected %}
|
||||||
|
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ch-picker-label">Available</div>
|
||||||
|
<div id="available-chips" class="ch-chips">
|
||||||
|
{% for ch in all_channels %}
|
||||||
|
{% if ch.name not in (current_user.notification_channels or []) %}
|
||||||
|
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||||||
|
+ {{ ch.name | e }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="no-hosts">No personal notification channels configured.</span>
|
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="save-row">
|
||||||
|
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
||||||
|
<span id="channels-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="no-hosts">Log in to manage notification channels.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My Channels — create/edit/delete own channels -->
|
||||||
|
{% if current_user %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>My Channels</h2>
|
||||||
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
|
||||||
|
<div id="my-channels-list">
|
||||||
|
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
||||||
|
{% for ch in my_channels %}
|
||||||
|
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
|
||||||
|
<div class="my-ch-header">
|
||||||
|
<span class="my-ch-name">{{ ch.name | e }}</span>
|
||||||
|
<span class="my-ch-type">{{ ch.type | e }}</span>
|
||||||
|
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
|
||||||
|
<span class="my-ch-actions">
|
||||||
|
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
||||||
|
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not my_channels %}
|
||||||
|
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="save-row" style="margin-top:8px">
|
||||||
|
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My Channels modal -->
|
||||||
|
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
|
||||||
|
<div class="ch-modal-box">
|
||||||
|
<h3 id="my-ch-modal-title">New Channel</h3>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Channel name</label>
|
||||||
|
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="my-ch-type" onchange="onMyChTypeChange()">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="my-ch-type-fields"></div>
|
||||||
|
<div class="ch-form-divider">Options</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Minimum alert level</label>
|
||||||
|
<select id="my-ch-min-level">
|
||||||
|
<option value="WARNING">WARNING (and above)</option>
|
||||||
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="my-ch-private"> Private — visible only to you
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="my-ch-modal-status" class="ch-modal-status"></div>
|
||||||
|
<div class="ch-modal-footer">
|
||||||
|
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
||||||
|
<button class="btn-save" onclick="saveMyChannel()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Host access -->
|
<!-- Host access -->
|
||||||
@@ -326,5 +585,258 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<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 ----
|
||||||
|
async function saveIdentity() {
|
||||||
|
const full_name = document.getElementById('profile-fullname').value;
|
||||||
|
const avatar = document.getElementById('profile-avatar').value;
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({full_name, avatar}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showStatus('identity-status', 'Saved', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('identity-status', err.error || 'Error saving', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Password ----
|
||||||
|
async function changePassword() {
|
||||||
|
const current = document.getElementById('profile-current-pw').value;
|
||||||
|
const newpw = document.getElementById('profile-new-pw').value;
|
||||||
|
if (!current || !newpw) {
|
||||||
|
showStatus('password-status', 'Both fields are required', '#c62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password: {current, new: newpw}}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('profile-current-pw').value = '';
|
||||||
|
document.getElementById('profile-new-pw').value = '';
|
||||||
|
showStatus('password-status', 'Password changed', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('password-status', err.error || 'Error', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Channel chip picker ----
|
||||||
|
function toggleChip(btn) {
|
||||||
|
const name = btn.dataset.ch;
|
||||||
|
const isSelected = btn.classList.contains('selected');
|
||||||
|
if (isSelected) {
|
||||||
|
// Move to available
|
||||||
|
btn.classList.remove('selected');
|
||||||
|
btn.classList.add('available');
|
||||||
|
btn.innerHTML = '+ ' + escHtml(name);
|
||||||
|
btn.onclick = function() { toggleChip(this); };
|
||||||
|
document.getElementById('available-chips').appendChild(btn);
|
||||||
|
// Remove "None selected" placeholder if it exists
|
||||||
|
} else {
|
||||||
|
// Move to selected
|
||||||
|
btn.classList.remove('available');
|
||||||
|
btn.classList.add('selected');
|
||||||
|
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
|
||||||
|
btn.onclick = function() { toggleChip(this); };
|
||||||
|
document.getElementById('selected-chips').appendChild(btn);
|
||||||
|
}
|
||||||
|
// Update placeholder visibility
|
||||||
|
const sel = document.getElementById('selected-chips');
|
||||||
|
const placeholder = sel.querySelector('span[style]');
|
||||||
|
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
|
||||||
|
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChannels() {
|
||||||
|
const notification_channels = [
|
||||||
|
...document.querySelectorAll('#selected-chips .ch-chip.selected')
|
||||||
|
].map(b => b.dataset.ch);
|
||||||
|
const resp = await fetch('/api/0/users/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({notification_channels}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showStatus('channels-status', 'Saved', '#2e7d32');
|
||||||
|
} else {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showStatus('channels-status', err.error || 'Error saving', '#c62828');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- My Channels CRUD ----
|
||||||
|
let _myChSchemas = {};
|
||||||
|
let _myChEditName = null;
|
||||||
|
|
||||||
|
async function _loadMyChSchemas() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channel_types');
|
||||||
|
_myChSchemas = await r.json();
|
||||||
|
const sel = document.getElementById('my-ch-type');
|
||||||
|
if (!sel) return;
|
||||||
|
Object.entries(_myChSchemas).forEach(([k, v]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = k; opt.textContent = v.label;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMyChTypeChange() {
|
||||||
|
const type = document.getElementById('my-ch-type').value;
|
||||||
|
const container = document.getElementById('my-ch-type-fields');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!type || !_myChSchemas[type]) return;
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'ch-form-divider';
|
||||||
|
divider.textContent = _myChSchemas[type].label + ' settings';
|
||||||
|
container.appendChild(divider);
|
||||||
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ch-form-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||||||
|
inp.id = 'mychf-' + sf.key;
|
||||||
|
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||||||
|
inp.autocomplete = 'off';
|
||||||
|
row.appendChild(lbl);
|
||||||
|
row.appendChild(inp);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMyChModal(name) {
|
||||||
|
_myChEditName = name || null;
|
||||||
|
document.getElementById('my-ch-modal-status').textContent = '';
|
||||||
|
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
|
||||||
|
document.getElementById('my-ch-name').value = name || '';
|
||||||
|
document.getElementById('my-ch-name').disabled = !!name;
|
||||||
|
document.getElementById('my-ch-type').value = '';
|
||||||
|
document.getElementById('my-ch-type-fields').innerHTML = '';
|
||||||
|
document.getElementById('my-ch-min-level').value = 'WARNING';
|
||||||
|
document.getElementById('my-ch-private').checked = false;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels');
|
||||||
|
const channels = await r.json();
|
||||||
|
const ch = channels.find(c => c.name === name);
|
||||||
|
if (ch) {
|
||||||
|
document.getElementById('my-ch-type').value = ch.type;
|
||||||
|
onMyChTypeChange();
|
||||||
|
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
|
document.getElementById('my-ch-private').checked = ch.private || false;
|
||||||
|
(ch.fields || []).forEach(f => {
|
||||||
|
const inp = document.getElementById('mychf-' + f.key);
|
||||||
|
if (inp) inp.value = f.value || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('Failed to load channel', e); }
|
||||||
|
}
|
||||||
|
document.getElementById('my-ch-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMyChModal() {
|
||||||
|
document.getElementById('my-ch-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMyChannel() {
|
||||||
|
const name = document.getElementById('my-ch-name').value.trim();
|
||||||
|
const type = document.getElementById('my-ch-type').value;
|
||||||
|
const minLevel = document.getElementById('my-ch-min-level').value;
|
||||||
|
const isPrivate = document.getElementById('my-ch-private').checked;
|
||||||
|
const statusEl = document.getElementById('my-ch-modal-status');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
|
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||||||
|
if (_myChSchemas[type]) {
|
||||||
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const inp = document.getElementById('mychf-' + sf.key);
|
||||||
|
if (inp) body[sf.key] = inp.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!_myChEditName;
|
||||||
|
const url = isEdit
|
||||||
|
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
|
||||||
|
: '/api/0/notification_channels';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
|
if (r.ok) {
|
||||||
|
closeMyChModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
statusEl.textContent = err.error || 'Error saving.';
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.textContent = 'Network error: ' + e.message;
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMyChannel(name) {
|
||||||
|
if (!confirm('Delete channel "' + name + '"?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
alert('Error: ' + (err.error || 'Could not delete.'));
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Network error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Utilities ----
|
||||||
|
function showStatus(id, msg, color) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = color;
|
||||||
|
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1299
-188
File diff suppressed because it is too large
Load Diff
+93
-18
@@ -195,6 +195,7 @@ class ThresholdConfig:
|
|||||||
hysteresis: float = 0.0,
|
hysteresis: float = 0.0,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
count: int = 1,
|
count: int = 1,
|
||||||
|
grace: Optional[float] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize threshold configuration.
|
Initialize threshold configuration.
|
||||||
@@ -207,6 +208,7 @@ class ThresholdConfig:
|
|||||||
hysteresis: Hysteresis percentage to prevent flapping (0.0-1.0)
|
hysteresis: Hysteresis percentage to prevent flapping (0.0-1.0)
|
||||||
enabled: Whether this threshold is enabled
|
enabled: Whether this threshold is enabled
|
||||||
count: Number of consecutive exceedances required before alerting (default 1)
|
count: Number of consecutive exceedances required before alerting (default 1)
|
||||||
|
grace: Per-metric grace period in seconds; overrides global grace when set
|
||||||
"""
|
"""
|
||||||
self.metric_path = metric_path
|
self.metric_path = metric_path
|
||||||
self.warning = warning
|
self.warning = warning
|
||||||
@@ -215,6 +217,7 @@ class ThresholdConfig:
|
|||||||
self.hysteresis = hysteresis
|
self.hysteresis = hysteresis
|
||||||
self.display = display
|
self.display = display
|
||||||
self.count = max(1, int(count))
|
self.count = max(1, int(count))
|
||||||
|
self.grace = float(grace) if grace is not None else None
|
||||||
|
|
||||||
# Parse operator
|
# Parse operator
|
||||||
try:
|
try:
|
||||||
@@ -492,7 +495,27 @@ class ThresholdChecker:
|
|||||||
raw_overrides: Dict[str, ThresholdConfig] = {}
|
raw_overrides: Dict[str, ThresholdConfig] = {}
|
||||||
thresholds_config = config_data["thresholds"]
|
thresholds_config = config_data["thresholds"]
|
||||||
for plugin_name, plugin_thresholds in thresholds_config.items():
|
for plugin_name, plugin_thresholds in thresholds_config.items():
|
||||||
if isinstance(plugin_thresholds, dict):
|
if not isinstance(plugin_thresholds, dict):
|
||||||
|
continue
|
||||||
|
plugin_enabled = plugin_thresholds.get('enabled', plugin_thresholds.get('enable', True))
|
||||||
|
if not plugin_enabled:
|
||||||
|
# raw_overrides is empty at this point so there's nothing to delete.
|
||||||
|
# Instead, inject disabled stubs for every matching effective_default so
|
||||||
|
# the merge step overwrites the inherited defaults.
|
||||||
|
for key, tc in effective_defaults.items():
|
||||||
|
if key.startswith(f"{plugin_name}."):
|
||||||
|
raw_overrides[key] = ThresholdConfig(
|
||||||
|
metric_path=key,
|
||||||
|
warning=tc.warning,
|
||||||
|
critical=tc.critical,
|
||||||
|
operator=tc.operator.value,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Plugin-level disable in config '%s': disabled all thresholds for %s",
|
||||||
|
config_name, plugin_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=raw_overrides)
|
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=raw_overrides)
|
||||||
self.threshold_raw_configs[config_name] = raw_overrides
|
self.threshold_raw_configs[config_name] = raw_overrides
|
||||||
|
|
||||||
@@ -570,7 +593,16 @@ class ThresholdChecker:
|
|||||||
if plugin_name == "rtt":
|
if plugin_name == "rtt":
|
||||||
self._parse_rtt_thresholds(thresholds, target_dict)
|
self._parse_rtt_thresholds(thresholds, target_dict)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Plugin-level enabled: false (also accept 'enable' as a common typo) removes all
|
||||||
|
# thresholds for this plugin — e.g. memory_monitor: {enabled: false}.
|
||||||
|
plugin_enabled = thresholds.get('enabled', thresholds.get('enable', True))
|
||||||
|
if not plugin_enabled:
|
||||||
|
for key in [k for k in target_dict if k.startswith(f"{plugin_name}.")]:
|
||||||
|
del target_dict[key]
|
||||||
|
logger.info("Plugin-level disable: removed all thresholds for %s", plugin_name)
|
||||||
|
return
|
||||||
|
|
||||||
for metric_name, threshold_config in thresholds.items():
|
for metric_name, threshold_config in thresholds.items():
|
||||||
if not isinstance(threshold_config, dict):
|
if not isinstance(threshold_config, dict):
|
||||||
continue
|
continue
|
||||||
@@ -595,11 +627,12 @@ class ThresholdChecker:
|
|||||||
display = threshold_config.get("display", default_display)
|
display = threshold_config.get("display", default_display)
|
||||||
hysteresis = threshold_config.get("hysteresis", 0.0 if is_nagios_op else 0.02)
|
hysteresis = threshold_config.get("hysteresis", 0.0 if is_nagios_op else 0.02)
|
||||||
enabled = threshold_config.get("enabled", True)
|
enabled = threshold_config.get("enabled", True)
|
||||||
|
grace = threshold_config.get("grace", None)
|
||||||
|
|
||||||
if warning is None and critical is None and not is_nagios_op:
|
if warning is None and critical is None and not is_nagios_op:
|
||||||
logger.warning("No thresholds defined for %s, skipping", metric_path)
|
logger.warning("No thresholds defined for %s, skipping", metric_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
threshold = ThresholdConfig(
|
threshold = ThresholdConfig(
|
||||||
metric_path=metric_path,
|
metric_path=metric_path,
|
||||||
warning=warning,
|
warning=warning,
|
||||||
@@ -607,7 +640,8 @@ class ThresholdChecker:
|
|||||||
operator=operator,
|
operator=operator,
|
||||||
hysteresis=hysteresis,
|
hysteresis=hysteresis,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
display=display
|
display=display,
|
||||||
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dict[metric_path] = threshold
|
target_dict[metric_path] = threshold
|
||||||
@@ -652,9 +686,10 @@ class ThresholdChecker:
|
|||||||
hysteresis = threshold_config.get("hysteresis", 0.1)
|
hysteresis = threshold_config.get("hysteresis", 0.1)
|
||||||
enabled = threshold_config.get("enabled", True)
|
enabled = threshold_config.get("enabled", True)
|
||||||
display = threshold_config.get("display")
|
display = threshold_config.get("display")
|
||||||
|
grace = threshold_config.get("grace", None)
|
||||||
if warning is None and critical is None:
|
if warning is None and critical is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
threshold = ThresholdConfig(
|
threshold = ThresholdConfig(
|
||||||
metric_path=metric_path,
|
metric_path=metric_path,
|
||||||
warning=warning,
|
warning=warning,
|
||||||
@@ -662,7 +697,8 @@ class ThresholdChecker:
|
|||||||
operator=operator,
|
operator=operator,
|
||||||
hysteresis=hysteresis,
|
hysteresis=hysteresis,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
display=display
|
display=display,
|
||||||
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dict[metric_path] = threshold
|
target_dict[metric_path] = threshold
|
||||||
@@ -705,6 +741,7 @@ class ThresholdChecker:
|
|||||||
hysteresis = threshold_config.get("hysteresis", 0.02)
|
hysteresis = threshold_config.get("hysteresis", 0.02)
|
||||||
enabled = threshold_config.get("enabled", True)
|
enabled = threshold_config.get("enabled", True)
|
||||||
display = threshold_config.get("display")
|
display = threshold_config.get("display")
|
||||||
|
grace = threshold_config.get("grace", None)
|
||||||
if warning is None and critical is None:
|
if warning is None and critical is None:
|
||||||
continue
|
continue
|
||||||
target_dict[metric_path] = ThresholdConfig(
|
target_dict[metric_path] = ThresholdConfig(
|
||||||
@@ -715,6 +752,7 @@ class ThresholdChecker:
|
|||||||
hysteresis=hysteresis,
|
hysteresis=hysteresis,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
display=display,
|
display=display,
|
||||||
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_rtt_thresholds(
|
def _parse_rtt_thresholds(
|
||||||
@@ -750,6 +788,7 @@ class ThresholdChecker:
|
|||||||
enabled = rtt_thresholds.get("enabled", True)
|
enabled = rtt_thresholds.get("enabled", True)
|
||||||
display = rtt_thresholds.get("display")
|
display = rtt_thresholds.get("display")
|
||||||
count = rtt_thresholds.get("count", 1)
|
count = rtt_thresholds.get("count", 1)
|
||||||
|
grace = rtt_thresholds.get("grace", None)
|
||||||
|
|
||||||
if warning is None and critical is None:
|
if warning is None and critical is None:
|
||||||
logger.warning("No RTT thresholds defined, skipping")
|
logger.warning("No RTT thresholds defined, skipping")
|
||||||
@@ -764,6 +803,7 @@ class ThresholdChecker:
|
|||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
display=display,
|
display=display,
|
||||||
count=count,
|
count=count,
|
||||||
|
grace=grace,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dict[metric_path] = threshold
|
target_dict[metric_path] = threshold
|
||||||
@@ -1324,7 +1364,9 @@ class ThresholdChecker:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a state-change transition with grace-period logic.
|
"""Handle a state-change transition with grace-period logic.
|
||||||
|
|
||||||
Transitioning INTO alert (worsening): defers the notification for grace_seconds.
|
Transitioning INTO alert (worsening): defers the notification for the effective
|
||||||
|
grace period (threshold.grace if set, else self.grace_seconds). Grace of 0 fires
|
||||||
|
the notification immediately with no deferral.
|
||||||
De-escalation within alert states (e.g. CRITICAL→WARNING): no new notification;
|
De-escalation within alert states (e.g. CRITICAL→WARNING): no new notification;
|
||||||
the metric is still alerting so no RECOVER was sent.
|
the metric is still alerting so no RECOVER was sent.
|
||||||
Transitioning TO OK:
|
Transitioning TO OK:
|
||||||
@@ -1332,6 +1374,8 @@ class ThresholdChecker:
|
|||||||
and the recovery — the spike never warranted a page.
|
and the recovery — the spike never warranted a page.
|
||||||
- Past grace: fires the RECOVER notification normally.
|
- Past grace: fires the RECOVER notification normally.
|
||||||
"""
|
"""
|
||||||
|
effective_grace = threshold.grace if threshold.grace is not None else self.grace_seconds
|
||||||
|
|
||||||
lvl, message, formatted_msg = self._trigger_notification(
|
lvl, message, formatted_msg = self._trigger_notification(
|
||||||
host_name, metric_path, old_level, new_level, value, threshold, plugin_data,
|
host_name, metric_path, old_level, new_level, value, threshold, plugin_data,
|
||||||
check_name=check_name, metric_name=metric_name,
|
check_name=check_name, metric_name=metric_name,
|
||||||
@@ -1342,18 +1386,25 @@ class ThresholdChecker:
|
|||||||
if alert_state.pending_since is not None:
|
if alert_state.pending_since is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Alert suppressed (recovered within %.0fs grace): %s on %s",
|
"Alert suppressed (recovered within %.0fs grace): %s on %s",
|
||||||
self.grace_seconds, metric_path, host_name,
|
effective_grace, metric_path, host_name,
|
||||||
)
|
)
|
||||||
alert_state.pending_since = None
|
alert_state.pending_since = None
|
||||||
else:
|
else:
|
||||||
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
||||||
elif new_level.value > old_level.value:
|
elif new_level.value > old_level.value:
|
||||||
# Worsening (OK→WARNING, OK→CRITICAL, WARNING→CRITICAL): schedule notification.
|
# Worsening (OK→WARNING, OK→CRITICAL, WARNING→CRITICAL).
|
||||||
alert_state.pending_since = time.time()
|
if effective_grace <= 0:
|
||||||
logger.debug(
|
# No grace period — fire immediately.
|
||||||
"Alert deferred (%.0fs grace): %s on %s = %s",
|
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
||||||
self.grace_seconds, metric_path, host_name, value,
|
now = time.time()
|
||||||
)
|
alert_state.last_notification = now
|
||||||
|
alert_state.notification_count = 1
|
||||||
|
else:
|
||||||
|
alert_state.pending_since = time.time()
|
||||||
|
logger.debug(
|
||||||
|
"Alert deferred (%.0fs grace): %s on %s = %s",
|
||||||
|
effective_grace, metric_path, host_name, value,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# De-escalation within alert states (e.g. CRITICAL→WARNING): metric is still
|
# De-escalation within alert states (e.g. CRITICAL→WARNING): metric is still
|
||||||
# alerting but did not recover, so no new notification.
|
# alerting but did not recover, so no new notification.
|
||||||
@@ -1378,8 +1429,9 @@ class ThresholdChecker:
|
|||||||
If a deferred notification is pending and grace_seconds have elapsed,
|
If a deferred notification is pending and grace_seconds have elapsed,
|
||||||
fires it now. Otherwise falls through to normal reminder logic.
|
fires it now. Otherwise falls through to normal reminder logic.
|
||||||
"""
|
"""
|
||||||
|
effective_grace = threshold.grace if threshold.grace is not None else self.grace_seconds
|
||||||
if alert_state.pending_since is not None:
|
if alert_state.pending_since is not None:
|
||||||
if time.time() - alert_state.pending_since >= self.grace_seconds:
|
if time.time() - alert_state.pending_since >= effective_grace:
|
||||||
lvl, message, formatted_msg = self._trigger_notification(
|
lvl, message, formatted_msg = self._trigger_notification(
|
||||||
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data,
|
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data,
|
||||||
check_name=check_name, metric_name=metric_name,
|
check_name=check_name, metric_name=metric_name,
|
||||||
@@ -1389,10 +1441,24 @@ class ThresholdChecker:
|
|||||||
host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value
|
host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value
|
||||||
)
|
)
|
||||||
alert_state.pending_since = None
|
alert_state.pending_since = None
|
||||||
|
now = time.time()
|
||||||
|
alert_state.last_notification = now
|
||||||
|
alert_state.notification_count = 1
|
||||||
# else: still within grace window, do nothing
|
# else: still within grace window, do nothing
|
||||||
else:
|
else:
|
||||||
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name)
|
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _human_duration(seconds: float) -> str:
|
||||||
|
s = int(seconds)
|
||||||
|
if s < 120:
|
||||||
|
return f"{s}s"
|
||||||
|
if s < 3600:
|
||||||
|
return f"{s // 60}m {s % 60}s"
|
||||||
|
h, rem = divmod(s, 3600)
|
||||||
|
m = rem // 60
|
||||||
|
return f"{h}h {m}m" if m else f"{h}h"
|
||||||
|
|
||||||
def _check_renotify(
|
def _check_renotify(
|
||||||
self,
|
self,
|
||||||
host_name: str,
|
host_name: str,
|
||||||
@@ -1454,9 +1520,9 @@ class ThresholdChecker:
|
|||||||
check_name=check_name,
|
check_name=check_name,
|
||||||
metric_name=metric_name,
|
metric_name=metric_name,
|
||||||
)
|
)
|
||||||
body = f"{value} {threshold_info}, ongoing for {int(now - alert_state.since)}s"
|
body = f"{value} {threshold_info}, ongoing for {self._human_duration(now - alert_state.since)}"
|
||||||
else:
|
else:
|
||||||
body = f"{value} (ongoing for {int(now - alert_state.since)}s)"
|
body = f"{value} (ongoing for {self._human_duration(now - alert_state.since)})"
|
||||||
message = f"REMINDER ({alert_state.level.name}): {host_name} - {short_path} = {body}"
|
message = f"REMINDER ({alert_state.level.name}): {host_name} - {short_path} = {body}"
|
||||||
|
|
||||||
from . import hbdclass
|
from . import hbdclass
|
||||||
@@ -1486,7 +1552,16 @@ class ThresholdChecker:
|
|||||||
if not host.alert_states:
|
if not host.alert_states:
|
||||||
continue
|
continue
|
||||||
configured = self.get_thresholds_for_host(hostname)
|
configured = self.get_thresholds_for_host(hostname)
|
||||||
stale = [mp for mp in host.alert_states if self._find_threshold(configured, mp)[0] is None]
|
stale = []
|
||||||
|
for mp in host.alert_states:
|
||||||
|
if self._find_threshold(configured, mp)[0] is not None:
|
||||||
|
continue
|
||||||
|
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
|
||||||
|
# covers alert state "zfs_monitor.tank.status").
|
||||||
|
parts = mp.split(".")
|
||||||
|
if len(parts) == 3 and f"{parts[0]}.*.{parts[2]}" in configured:
|
||||||
|
continue
|
||||||
|
stale.append(mp)
|
||||||
for mp in stale:
|
for mp in stale:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Purging stale alert state for %s / %s (no threshold configured)",
|
"Purging stale alert state for %s / %s (no threshold configured)",
|
||||||
|
|||||||
+24
-1
@@ -232,6 +232,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.
|
||||||
|
|
||||||
@@ -333,6 +350,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"])
|
||||||
@@ -370,6 +389,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
if k not in ("ID", "plugin", "id", "name")}
|
if k not in ("ID", "plugin", "id", "name")}
|
||||||
# Store plugin data with timestamp
|
# Store plugin data with timestamp
|
||||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||||
|
# Reset stale timer — 3× the heartbeat interval (min 60 s)
|
||||||
|
stale_timeout = max(host.interval * 3, 60)
|
||||||
|
host.reset_plugin_timer(plugin_name, stale_timeout,
|
||||||
|
_make_plugin_stale_callback(uname, ctx))
|
||||||
|
|
||||||
# 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":
|
||||||
@@ -377,7 +400,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
default_owner = config_mod.get_default_owner(cfg)
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
||||||
host.owner = inferred_owner
|
host.owner = inferred_owner
|
||||||
logger.info(f"owner for {uname} is '{host.owner}")
|
logger.info(f"owner for {uname} is {host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
print(f"Stored plugin data for {uname}: {plugin_name}")
|
print(f"Stored plugin data for {uname}: {plugin_name}")
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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):
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -4,20 +4,32 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.2.5"
|
version = "5.3.10"
|
||||||
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"
|
||||||
license = "MIT"
|
|
||||||
keywords = ["heartbeat", "monitoring", "dns", "websocket", "system-monitoring"]
|
|
||||||
authors = [
|
|
||||||
{ name = "heartbeat contributors" }
|
|
||||||
]
|
|
||||||
|
|
||||||
# Core dependencies (required for both client and server)
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
]
|
]
|
||||||
|
license = "MIT"
|
||||||
|
license-files = ["LICENSE.md"]
|
||||||
|
keywords = ["heartbeat", "monitoring", "dns", "websocket", "system-monitoring"]
|
||||||
|
authors = [
|
||||||
|
{ name = "Andreas Wrede" }
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: POSIX :: BSD",
|
||||||
|
"Topic :: System :: Monitoring",
|
||||||
|
"Topic :: System :: Networking :: Monitoring",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://git.wrede.ca/andreas/heartbeat"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# Client-only dependencies (hbc - system monitoring client)
|
# Client-only dependencies (hbc - system monitoring client)
|
||||||
@@ -32,6 +44,7 @@ server = [
|
|||||||
"aiohttp>=3.11",
|
"aiohttp>=3.11",
|
||||||
"Jinja2>=3.1.6",
|
"Jinja2>=3.1.6",
|
||||||
"matrix-nio>=0.24",
|
"matrix-nio>=0.24",
|
||||||
|
"ruamel.yaml>=0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Minimal client — hbc_mini only, no external dependencies
|
# Minimal client — hbc_mini only, no external dependencies
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
key "rndc-key" {
|
|
||||||
algorithm hmac-md5;
|
|
||||||
secret "qlGa+AYKtyOgWNuozqECMw==";
|
|
||||||
};
|
|
||||||
+16
-1
@@ -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
|
||||||
+43
-29
@@ -789,7 +789,7 @@ 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 */
|
||||||
@@ -896,9 +896,9 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -1264,6 +1264,8 @@ static void usage(const char *prog) {
|
|||||||
" -c FILE Config file (JSON)\n"
|
" -c FILE Config file (JSON)\n"
|
||||||
" -m MSG Send one-shot message\n"
|
" -m MSG Send one-shot message\n"
|
||||||
" -n NAME Override hostname\n"
|
" -n NAME Override hostname\n"
|
||||||
|
" -4 Use IPv4 only\n"
|
||||||
|
" -6 Use IPv6 only\n"
|
||||||
" -d Daemonize\n"
|
" -d Daemonize\n"
|
||||||
" -v Verbose (info)\n"
|
" -v Verbose (info)\n"
|
||||||
" -x Debug\n"
|
" -x Debug\n"
|
||||||
@@ -1276,9 +1278,10 @@ int main(int argc, char **argv) {
|
|||||||
const char *cfgpath = NULL;
|
const char *cfgpath = NULL;
|
||||||
const char *message = NULL;
|
const char *message = NULL;
|
||||||
const char *nameov = NULL;
|
const char *nameov = NULL;
|
||||||
|
int af_filter = 0;
|
||||||
|
|
||||||
int opt;
|
int opt;
|
||||||
while ((opt = getopt(argc, argv, "bc:m:n:dvxh")) != -1) {
|
while ((opt = getopt(argc, argv, "bc:m:n:dvxh46")) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 'b': do_boot = true; break;
|
case 'b': do_boot = true; break;
|
||||||
case 'c': cfgpath = optarg; break;
|
case 'c': cfgpath = optarg; break;
|
||||||
@@ -1287,6 +1290,8 @@ int main(int argc, char **argv) {
|
|||||||
case 'd': do_daemon = true; break;
|
case 'd': do_daemon = true; break;
|
||||||
case 'v': g_log_level = LL_INFO; break;
|
case 'v': g_log_level = LL_INFO; break;
|
||||||
case 'x': g_log_level = LL_DEBUG; break;
|
case 'x': g_log_level = LL_DEBUG; break;
|
||||||
|
case '4': af_filter = AF_INET; break;
|
||||||
|
case '6': af_filter = AF_INET6; break;
|
||||||
case 'h': usage(argv[0]); return 0;
|
case 'h': usage(argv[0]); return 0;
|
||||||
default: usage(argv[0]); return 1;
|
default: usage(argv[0]); return 1;
|
||||||
}
|
}
|
||||||
@@ -1313,37 +1318,46 @@ int main(int argc, char **argv) {
|
|||||||
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
int conn_id = 1;
|
|
||||||
for (int i = 0; i < nhost; i++) {
|
|
||||||
struct addrinfo hints = {0}, *res = NULL;
|
|
||||||
hints.ai_socktype = SOCK_DGRAM;
|
|
||||||
hints.ai_protocol = IPPROTO_UDP;
|
|
||||||
char ps[16]; snprintf(ps, sizeof(ps), "%d", cfg.hb_port);
|
|
||||||
if (getaddrinfo(hosts[i], ps, &hints, &res) != 0) {
|
|
||||||
LOGE("cannot resolve %s", hosts[i]); continue;
|
|
||||||
}
|
|
||||||
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
|
|
||||||
conn_t *c = &g_conns[g_nconns];
|
|
||||||
memset(c, 0, sizeof(*c));
|
|
||||||
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
|
||||||
c->af = ai->ai_family; c->sockfd = -1;
|
|
||||||
snprintf(c->name, sizeof(c->name), "%s", iam);
|
|
||||||
void *addr = (ai->ai_family == AF_INET)
|
|
||||||
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
|
||||||
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
|
||||||
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
|
||||||
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
|
||||||
}
|
|
||||||
freeaddrinfo(res);
|
|
||||||
}
|
|
||||||
if (!g_nconns) { LOGE("no connections established"); return 1; }
|
|
||||||
|
|
||||||
struct sigaction sa = {0};
|
struct sigaction sa = {0};
|
||||||
sa.sa_handler = sig_handler;
|
sa.sa_handler = sig_handler;
|
||||||
sigaction(SIGTERM, &sa, NULL);
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
sigaction(SIGINT, &sa, NULL);
|
sigaction(SIGINT, &sa, NULL);
|
||||||
sigaction(SIGHUP, &sa, NULL);
|
sigaction(SIGHUP, &sa, NULL);
|
||||||
|
|
||||||
|
int conn_id = 1;
|
||||||
|
int retry_delay = 5;
|
||||||
|
while (g_running && !g_nconns) {
|
||||||
|
for (int i = 0; i < nhost; i++) {
|
||||||
|
struct addrinfo hints = {0}, *res = NULL;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
hints.ai_protocol = IPPROTO_UDP;
|
||||||
|
hints.ai_family = af_filter;
|
||||||
|
char ps[16]; snprintf(ps, sizeof(ps), "%d", cfg.hb_port);
|
||||||
|
if (getaddrinfo(hosts[i], ps, &hints, &res) != 0) {
|
||||||
|
LOGW("cannot resolve %s — retrying in %ds", hosts[i], retry_delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
|
||||||
|
conn_t *c = &g_conns[g_nconns];
|
||||||
|
memset(c, 0, sizeof(*c));
|
||||||
|
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
||||||
|
c->af = ai->ai_family; c->sockfd = -1;
|
||||||
|
snprintf(c->name, sizeof(c->name), "%s", iam);
|
||||||
|
void *addr = (ai->ai_family == AF_INET)
|
||||||
|
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
||||||
|
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
||||||
|
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
||||||
|
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
||||||
|
}
|
||||||
|
freeaddrinfo(res);
|
||||||
|
}
|
||||||
|
if (!g_nconns) {
|
||||||
|
sleep(retry_delay);
|
||||||
|
if (retry_delay < 60) retry_delay *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!g_nconns) return 1;
|
||||||
|
|
||||||
conn_t *primary = &g_conns[0];
|
conn_t *primary = &g_conns[0];
|
||||||
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
||||||
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
||||||
|
|||||||
+24
-13
@@ -41,7 +41,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.2.5"
|
__version__ = "5.3.10"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
@@ -1059,22 +1059,30 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
|
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
|
||||||
|
|
||||||
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
|
else 0)
|
||||||
|
|
||||||
connections: List[AsyncConnection] = []
|
connections: List[AsyncConnection] = []
|
||||||
conn_id = 1
|
conn_id = 1
|
||||||
for host in args.hosts:
|
_retry_delay = 5
|
||||||
try:
|
while _running and not connections:
|
||||||
addrs = socket.getaddrinfo(host, port, 0, 0, socket.SOL_UDP)
|
for host in args.hosts:
|
||||||
except socket.gaierror as e:
|
try:
|
||||||
log.error("cannot resolve %s: %s", host, e)
|
addrs = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
|
||||||
continue
|
except socket.gaierror as e:
|
||||||
for ai in addrs:
|
log.warning("cannot resolve %s: %s — retrying in %ds", host, e, _retry_delay)
|
||||||
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
continue
|
||||||
if await conn.open():
|
for ai in addrs:
|
||||||
connections.append(conn)
|
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
||||||
conn_id += 1
|
if await conn.open():
|
||||||
|
connections.append(conn)
|
||||||
|
conn_id += 1
|
||||||
|
if not connections:
|
||||||
|
await _sleep(_retry_delay)
|
||||||
|
_retry_delay = min(_retry_delay * 2, 60)
|
||||||
|
|
||||||
if not connections:
|
if not connections:
|
||||||
log.error("no connections established")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Boot / one-shot message
|
# Boot / one-shot message
|
||||||
@@ -1153,6 +1161,9 @@ def main(argv=None):
|
|||||||
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
|
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
|
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
|
||||||
|
af_group = parser.add_mutually_exclusive_group()
|
||||||
|
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
|
||||||
|
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
|
||||||
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
|
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from hbd.server import configio
|
||||||
|
|
||||||
|
SAMPLE_YAML = """\
|
||||||
|
# Server configuration
|
||||||
|
hbd_port: 50004 # HTTP API port
|
||||||
|
interval: 20
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
full_name: Alice Smith
|
||||||
|
admin: true
|
||||||
|
notification_channels:
|
||||||
|
pushover_ops:
|
||||||
|
type: pushover
|
||||||
|
token: abc123
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_roundtrip_loads_values(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
assert data["hbd_port"] == 50004
|
||||||
|
assert data["interval"] == 20
|
||||||
|
assert data["users"]["alice"]["full_name"] == "Alice Smith"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_creates_backup(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 30
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
backups = configio.list_backups(str(f))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert ".bak." in backups[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_preserves_comments(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 30
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
content = f.read_text()
|
||||||
|
assert "# Server configuration" in content
|
||||||
|
assert "# HTTP API port" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_atomically_replaces_file(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
data["interval"] = 99
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
assert not (tmp_path / ".hb.yaml.tmp").exists()
|
||||||
|
data2 = configio.read_roundtrip(str(f))
|
||||||
|
assert data2["interval"] == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_config_backup_rotation(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(SAMPLE_YAML)
|
||||||
|
# Pre-create 10 existing backups with old timestamps
|
||||||
|
for i in range(10):
|
||||||
|
(tmp_path / f".hb.yaml.bak.20260101-{i:06d}").write_text("old")
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 10
|
||||||
|
assert not (tmp_path / ".hb.yaml.bak.20260101-000000").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_backups_newest_first(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(SAMPLE_YAML)
|
||||||
|
for i in range(3):
|
||||||
|
(tmp_path / f".hb.yaml.bak.20260101-{i:02d}0000").write_text("b")
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 3
|
||||||
|
assert backups == sorted(backups, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_server_updates_keys(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60, "hbd_port": 8080})
|
||||||
|
assert data["interval"] == 60
|
||||||
|
assert data["hbd_port"] == 8080
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_server_ignores_unknown_keys(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60, "not_a_key": "x"})
|
||||||
|
assert "not_a_key" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_users_replaces_dict(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
new_users = {"bob": {"full_name": "Bob Jones", "admin": False}}
|
||||||
|
configio.apply_structured_section(data, "users", new_users)
|
||||||
|
assert "alice" not in data["users"]
|
||||||
|
assert data["users"]["bob"]["full_name"] == "Bob Jones"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_notification_channels(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
new_yaml = "email_ops:\n type: email\n recipients: [ops@example.com]\n"
|
||||||
|
configio.apply_yaml_section(data, "notification_channels", new_yaml)
|
||||||
|
assert "email_ops" in data["notification_channels"]
|
||||||
|
assert "pushover_ops" not in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_thresholds_maps_to_threshold_configs(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_yaml_section(data, "thresholds", "default:\n cpu: 80\n")
|
||||||
|
assert "threshold_configs" in data
|
||||||
|
assert data["threshold_configs"]["default"]["cpu"] == 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_dns_replaces_each_key(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_yaml_section(
|
||||||
|
data, "dns",
|
||||||
|
"nsupdate_bin: /usr/bin/nsupdate\ndyndomains: [dyn.example.com]\n"
|
||||||
|
)
|
||||||
|
assert data["nsupdate_bin"] == "/usr/bin/nsupdate"
|
||||||
|
assert data["dyndomains"] == ["dyn.example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_yaml_section_unknown_raises(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
with pytest.raises(ValueError, match="Unknown YAML section"):
|
||||||
|
configio.apply_yaml_section(data, "nope", "x: 1\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_structured_section_unknown_raises(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
with pytest.raises(ValueError, match="Unknown structured section"):
|
||||||
|
configio.apply_structured_section(data, "nope", {"x": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_roundtrip_missing_file_raises(tmp_path):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
configio.read_roundtrip(str(tmp_path / "nonexistent.yaml"))
|
||||||
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
|
|||||||
import hbdclass
|
import hbdclass
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"config": {"watchhosts": [], "dyndnshosts": []},
|
"config": {"watchhosts": []},
|
||||||
"hbdclass": hbdclass,
|
"hbdclass": hbdclass,
|
||||||
"log": dummy_noop,
|
"log": dummy_noop,
|
||||||
"email": dummy_noop,
|
"email": dummy_noop,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for the config read/write API helpers in http.py."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import http
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_masks_user_passwords():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "pbkdf2:sha256:abc"},
|
||||||
|
},
|
||||||
|
"oauth": {},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["users"]["alice"]["password"] == "•••"
|
||||||
|
assert result["users"]["alice"]["full_name"] == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_masks_oauth_client_secret():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"users": {},
|
||||||
|
"oauth": {
|
||||||
|
"gitea": {"type": "gitea", "url": "https://git.example.com",
|
||||||
|
"client_id": "cid", "client_secret": "verysecret"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["oauth"]["gitea"]["client_secret"] == "•••"
|
||||||
|
assert result["oauth"]["gitea"]["client_id"] == "cid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_includes_server_keys():
|
||||||
|
config = {"hbd_port": 50004, "interval": 20, "users": {}, "oauth": {}}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert result["server"]["hbd_port"] == 50004
|
||||||
|
assert result["server"]["interval"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_config_for_api_no_password_in_users_leaves_no_key():
|
||||||
|
config = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"users": {"bob": {"full_name": "Bob", "admin": False}},
|
||||||
|
"oauth": {},
|
||||||
|
}
|
||||||
|
result = http._mask_config_for_api(config)
|
||||||
|
assert "password" not in result["users"]["bob"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- configio integration for write path ----
|
||||||
|
|
||||||
|
def test_write_path_applies_server_section(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\ninterval: 20\nusers: {}\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.apply_structured_section(data, "server", {"interval": 60})
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["interval"] == 60
|
||||||
|
assert data2["hbd_port"] == 50004 # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_applies_yaml_section(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
"hbd_port: 50004\nnotification_channels:\n old_ch:\n type: email\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
configio.apply_yaml_section(data, "notification_channels", "new_ch:\n type: pushover\n")
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert "new_ch" in data2["notification_channels"]
|
||||||
|
assert "old_ch" not in data2["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_hashes_plaintext_password(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: pbkdf2:sha256:old\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what the POST handler does: hash plaintext password
|
||||||
|
new_users = {"alice": {"full_name": "Alice", "admin": True, "password": "newplaintext"}}
|
||||||
|
for username, attrs in new_users.items():
|
||||||
|
pw = attrs.get("password", "")
|
||||||
|
if pw and not pw.startswith("pbkdf2:"):
|
||||||
|
attrs["password"] = users_mod.hash_password(pw)
|
||||||
|
configio.apply_structured_section(data, "users", new_users)
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["users"]["alice"]["password"].startswith("pbkdf2:")
|
||||||
|
assert data2["users"]["alice"]["password"] != "newplaintext"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_restores_backup(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text("hbd_port: 50004\ninterval: 20\n")
|
||||||
|
from hbd.server import configio
|
||||||
|
# Make a change to create a backup
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
data["interval"] = 99
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
backups = configio.list_backups(str(cfg))
|
||||||
|
assert len(backups) == 1
|
||||||
|
# Read the backup and write it back (simulating rollback)
|
||||||
|
backup_data = configio.read_roundtrip(backups[0])
|
||||||
|
configio.write_config(str(cfg), backup_data)
|
||||||
|
restored = configio.read_roundtrip(str(cfg))
|
||||||
|
assert restored["interval"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_preserves_masked_password(tmp_path):
|
||||||
|
"""The "•••" sentinel must preserve the existing hash, not write "•••" to disk."""
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
original_hash = "pbkdf2:sha256:original_hash"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {original_hash}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what api_config_post does when client sends "•••" back
|
||||||
|
existing_users = data.get("users") or {}
|
||||||
|
users_payload = {"alice": {"full_name": "Alice", "admin": True, "password": "•••"}}
|
||||||
|
for username, attrs in users_payload.items():
|
||||||
|
pw = attrs.get("password", "")
|
||||||
|
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
|
||||||
|
attrs["password"] = users_mod.hash_password(pw)
|
||||||
|
elif not pw or pw == "•••":
|
||||||
|
existing_hash = (existing_users.get(username) or {}).get("password", "")
|
||||||
|
if existing_hash:
|
||||||
|
attrs["password"] = existing_hash
|
||||||
|
else:
|
||||||
|
attrs.pop("password", None)
|
||||||
|
configio.apply_structured_section(data, "users", users_payload)
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["users"]["alice"]["password"] == original_hash, (
|
||||||
|
f"Expected original hash preserved, got: {data2['users']['alice']['password']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_path_preserves_oauth_client_secret(tmp_path):
|
||||||
|
"""The "•••" sentinel for oauth client_secret must preserve the existing secret."""
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
original_secret = "real_client_secret_value"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\noauth:\n gitea:\n type: gitea\n url: https://git.example.com\n"
|
||||||
|
f" client_id: cid123\n client_secret: {original_secret}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
# Simulate what api_config_post does when client sends "•••" back for client_secret
|
||||||
|
existing_oauth = data.get("oauth") or {}
|
||||||
|
new_oauth = {"gitea": {"type": "gitea", "url": "https://git.example.com", "client_id": "cid123", "client_secret": "•••"}}
|
||||||
|
for name, attrs in new_oauth.items():
|
||||||
|
cs = attrs.get("client_secret", "")
|
||||||
|
if not cs or cs == "•••":
|
||||||
|
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
|
||||||
|
if existing_cs:
|
||||||
|
attrs["client_secret"] = existing_cs
|
||||||
|
else:
|
||||||
|
attrs.pop("client_secret", None)
|
||||||
|
data["oauth"] = new_oauth
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
data2 = configio.read_roundtrip(str(cfg))
|
||||||
|
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
||||||
|
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
def __init__(self, lastbeat):
|
||||||
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHost:
|
||||||
|
def __init__(self, name="myhost", owner=None, managers=None,
|
||||||
|
connections=None, os_data=None, plugin_data=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = managers or []
|
||||||
|
self.connections = connections or {}
|
||||||
|
self._os_data = os_data
|
||||||
|
self.plugin_data = plugin_data or {}
|
||||||
|
|
||||||
|
def get_latest_plugin_data(self, plugin_name):
|
||||||
|
if plugin_name == "os_info" and self._os_data is not None:
|
||||||
|
return (1234567890.0, self._os_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_basic_fields():
|
||||||
|
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] == "alice"
|
||||||
|
assert result["managers"] == ["bob", "carol"]
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_no_owner():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] is None
|
||||||
|
assert result["managers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_reads_hbc_from_os_info():
|
||||||
|
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] == "5.3.0"
|
||||||
|
assert result["hbc_type"] == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_hbc_none_when_no_os_info():
|
||||||
|
host = _FakeHost(os_data=None)
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConn(1000.0),
|
||||||
|
"IPv6": _FakeConn(2000.0),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_none_when_no_connections():
|
||||||
|
host = _FakeHost(connections={})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_none_without_checker():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=None)
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_sorted_by_metric():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||||
|
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||||
|
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {
|
||||||
|
"memory_monitor.memory_percent": tc_mem,
|
||||||
|
"cpu_monitor.cpu_percent": tc_cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
assert len(result["thresholds"]) == 2
|
||||||
|
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||||
|
assert result["thresholds"][0]["warning"] == 80.0
|
||||||
|
assert result["thresholds"][0]["critical"] == 95.0
|
||||||
|
assert result["thresholds"][0]["operator"] == ">"
|
||||||
|
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_threshold_null_warning_critical():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["warning"] is None
|
||||||
|
assert result["thresholds"][0]["critical"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_nagios_operator_serialized():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["operator"] == "nagios"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_suffix_matched_metrics():
|
||||||
|
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost(
|
||||||
|
connections={},
|
||||||
|
os_data=None,
|
||||||
|
)
|
||||||
|
# Simulate plugin_data with both percent and swap_percent fields
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {
|
||||||
|
"percent": 80.0,
|
||||||
|
"swap_percent": 25.0,
|
||||||
|
"available_mb": 2000,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["metric"] == "memory_monitor.percent"
|
||||||
|
assert t["covers"] == ["memory_monitor.swap_percent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_empty_when_exact_matches_only():
|
||||||
|
"""No covers when all plugin fields match their threshold exactly."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["covers"] == []
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests for PUT /api/0/users/me logic."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import users as users_mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_roundtrip():
|
||||||
|
h = users_mod.hash_password("mysecret")
|
||||||
|
assert h.startswith("pbkdf2:sha256:")
|
||||||
|
assert users_mod.authenticate.__doc__ is not None # module loaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_change_requires_correct_current(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
initial_hash = users_mod.hash_password("oldpass")
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
users_mod.load_users({"users": {"alice": {"full_name": "Alice", "admin": True, "password": initial_hash}}})
|
||||||
|
|
||||||
|
# Correct current password authenticates
|
||||||
|
assert users_mod.authenticate("alice", "oldpass") is not None
|
||||||
|
# Wrong current password does not authenticate
|
||||||
|
assert users_mod.authenticate("alice", "wrongpass") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_writes_new_fields(tmp_path):
|
||||||
|
"""Simulate the write path: read config, update user, write back."""
|
||||||
|
initial_hash = users_mod.hash_password("secret")
|
||||||
|
yaml_content = (
|
||||||
|
"hbd_port: 50004\n"
|
||||||
|
f"users:\n alice:\n full_name: Old Name\n admin: true\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(yaml_content)
|
||||||
|
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
|
||||||
|
# Simulate handler updating full_name and avatar
|
||||||
|
user_entry = dict(data["users"]["alice"])
|
||||||
|
user_entry["full_name"] = "New Name"
|
||||||
|
user_entry["avatar"] = "/img/alice.png"
|
||||||
|
data["users"]["alice"] = user_entry
|
||||||
|
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
assert result["users"]["alice"]["full_name"] == "New Name"
|
||||||
|
assert result["users"]["alice"]["avatar"] == "/img/alice.png"
|
||||||
|
assert result["users"]["alice"]["password"] == initial_hash # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_changes_password(tmp_path):
|
||||||
|
initial_hash = users_mod.hash_password("oldpass")
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n password: {initial_hash}\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
|
||||||
|
new_hash = users_mod.hash_password("newpass")
|
||||||
|
data["users"]["alice"]["password"] = new_hash
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
# Load users from new config and authenticate with new password
|
||||||
|
new_config = {"users": dict(result["users"])}
|
||||||
|
users_mod.load_users(new_config)
|
||||||
|
assert users_mod.authenticate("alice", "newpass") is not None
|
||||||
|
assert users_mod.authenticate("alice", "oldpass") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_users_me_notification_channels(tmp_path):
|
||||||
|
cfg = tmp_path / ".hb.yaml"
|
||||||
|
cfg.write_text(
|
||||||
|
"hbd_port: 50004\n"
|
||||||
|
"notification_channels:\n pushover_ops:\n type: pushover\n"
|
||||||
|
"users:\n alice:\n full_name: Alice\n notification_channels: []\n"
|
||||||
|
)
|
||||||
|
from hbd.server import configio
|
||||||
|
data = configio.read_roundtrip(str(cfg))
|
||||||
|
data["users"]["alice"]["notification_channels"] = ["pushover_ops"]
|
||||||
|
configio.write_config(str(cfg), data)
|
||||||
|
result = configio.read_roundtrip(str(cfg))
|
||||||
|
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_channels_excludes_private_from_others():
|
||||||
|
"""Private channels owned by another user must not appear in the visible set."""
|
||||||
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"notification_channels": {
|
||||||
|
"public_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
||||||
|
"recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"},
|
||||||
|
"bob_priv": {"type": "email", "owner": "bob", "private": True,
|
||||||
|
"recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeUser:
|
||||||
|
def __init__(self, username, admin=False):
|
||||||
|
self.username = username
|
||||||
|
self.admin = admin
|
||||||
|
|
||||||
|
alice = FakeUser("alice")
|
||||||
|
bob = FakeUser("bob")
|
||||||
|
admin = FakeUser("admin", admin=True)
|
||||||
|
|
||||||
|
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
|
||||||
|
def visible(user):
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user.admin:
|
||||||
|
return set(all_channels.keys())
|
||||||
|
return {
|
||||||
|
name for name, cfg in all_channels.items()
|
||||||
|
if not cfg.get("private") or cfg.get("owner") == user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
assert visible(alice) == {"public_ch", "alice_priv"}
|
||||||
|
assert visible(bob) == {"public_ch", "bob_priv"}
|
||||||
|
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import configio, settings as settings_mod
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_YAML = """\
|
||||||
|
hbd_port: 50004
|
||||||
|
notification_channels:
|
||||||
|
pushover_ops:
|
||||||
|
type: pushover
|
||||||
|
token: abc123
|
||||||
|
user: usr456
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# configio helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_apply_channel_adds_new_entry(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]})
|
||||||
|
assert "email_ops" in data["notification_channels"]
|
||||||
|
assert data["notification_channels"]["email_ops"]["type"] == "email"
|
||||||
|
# Existing channel preserved
|
||||||
|
assert "pushover_ops" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_updates_existing(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"})
|
||||||
|
assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_creates_section_if_absent():
|
||||||
|
data = {"hbd_port": 50004}
|
||||||
|
configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"})
|
||||||
|
assert "notification_channels" in data
|
||||||
|
assert "test_ch" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_removes_entry(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.delete_channel(data, "pushover_ops")
|
||||||
|
assert "pushover_ops" not in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_noop_for_missing():
|
||||||
|
data = {"notification_channels": {"ch1": {"type": "pushover"}}}
|
||||||
|
configio.delete_channel(data, "nonexistent") # must not raise
|
||||||
|
assert "ch1" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_noop_when_no_section():
|
||||||
|
data = {}
|
||||||
|
configio.delete_channel(data, "anything") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_persisted_after_write(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"})
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
result = configio.read_roundtrip(str(f))
|
||||||
|
assert "signal_ops" in result["notification_channels"]
|
||||||
|
assert result["notification_channels"]["signal_ops"]["user"] == "+1"
|
||||||
|
# Original channel preserved
|
||||||
|
assert "pushover_ops" in result["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_persisted_after_write(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.delete_channel(data, "pushover_ops")
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
result = configio.read_roundtrip(str(f))
|
||||||
|
assert "pushover_ops" not in (result.get("notification_channels") or {})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Visibility logic (mirrors http.py _visible_channels_for_user)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _visible(config, user):
|
||||||
|
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user.get("admin"):
|
||||||
|
return set(all_channels.keys())
|
||||||
|
username = user["username"]
|
||||||
|
return {
|
||||||
|
name for name, cfg in all_channels.items()
|
||||||
|
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_VISIBILITY = {
|
||||||
|
"notification_channels": {
|
||||||
|
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
||||||
|
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
||||||
|
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
|
||||||
|
"user": "+1", "recipient": "+2"},
|
||||||
|
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_channel_visible_to_all():
|
||||||
|
for uname in ("alice", "bob", "carol"):
|
||||||
|
user = {"username": uname, "admin": False}
|
||||||
|
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_channel_visible_only_to_owner():
|
||||||
|
alice = {"username": "alice", "admin": False}
|
||||||
|
bob = {"username": "bob", "admin": False}
|
||||||
|
carol = {"username": "carol", "admin": False}
|
||||||
|
|
||||||
|
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
|
||||||
|
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
|
||||||
|
|
||||||
|
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
||||||
|
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_all_channels():
|
||||||
|
admin = {"username": "adminuser", "admin": True}
|
||||||
|
visible = _visible(CONFIG_VISIBILITY, admin)
|
||||||
|
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_owned_channel_is_public_by_default():
|
||||||
|
alice = {"username": "alice", "admin": False}
|
||||||
|
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Channel type schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_all_required_types_in_schema():
|
||||||
|
for t in ("pushover", "email", "signal", "matrix", "sms_voipms"):
|
||||||
|
assert t in settings_mod.CHANNEL_TYPE_SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_fields_have_required_keys():
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
assert "label" in schema, f"{type_id} missing label"
|
||||||
|
assert "fields" in schema, f"{type_id} missing fields"
|
||||||
|
for f in schema["fields"]:
|
||||||
|
for k in ("key", "label", "type", "required"):
|
||||||
|
assert k in f, f"{type_id} field missing {k!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_fields_use_secret_type():
|
||||||
|
"""Known secret fields must be typed 'secret' so the UI masks them."""
|
||||||
|
secret_keys = {"token", "user_key", "api_key", "api_password",
|
||||||
|
"smtp_password", "access_token"}
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
for f in schema["fields"]:
|
||||||
|
if f["key"] in secret_keys:
|
||||||
|
assert f["type"] == "secret", (
|
||||||
|
f"{type_id}.{f['key']} should be type 'secret'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_labels_not_empty():
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
assert schema["label"].strip(), f"{type_id} has empty label"
|
||||||
+421
-143
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import time as time_mod
|
import time as time_mod
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
@@ -36,17 +37,6 @@ def reset_users_dict():
|
|||||||
users_mod.users = original
|
users_mod.users = original
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_when_all_keys_present():
|
|
||||||
assert oauth.is_enabled(CFG_ON) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_no_oauth_key():
|
|
||||||
assert oauth.is_enabled(CFG_OFF) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_enabled_false_when_partial_config():
|
|
||||||
assert oauth.is_enabled(CFG_PARTIAL) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_state_returns_unique_tokens():
|
def test_make_state_returns_unique_tokens():
|
||||||
s1 = oauth.make_state()
|
s1 = oauth.make_state()
|
||||||
@@ -134,132 +124,6 @@ def test_provision_oauth_user_survives_config_reload():
|
|||||||
assert "oauthonly" in users_mod.users
|
assert "oauthonly" in users_mod.users
|
||||||
|
|
||||||
|
|
||||||
def test_authorization_url_shape():
|
|
||||||
state = "teststate"
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
|
|
||||||
parsed = urlparse(url)
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
assert parsed.scheme == "https"
|
|
||||||
assert parsed.netloc == "git.example.com"
|
|
||||||
assert parsed.path == "/login/oauth/authorize"
|
|
||||||
assert qs["client_id"] == ["cid"]
|
|
||||||
assert qs["state"] == ["teststate"]
|
|
||||||
assert qs["redirect_uri"] == [redirect_uri]
|
|
||||||
assert qs["scope"] == ["user:email"]
|
|
||||||
assert qs["response_type"] == ["code"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_returns_token():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
|
|
||||||
assert token == "tok123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_raises_on_error_status():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 401
|
|
||||||
mock_response.text = AsyncMock(return_value="unauthorized")
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
with pytest.raises(oauth.OAuthError):
|
|
||||||
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_user_returns_profile():
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
profile = await oauth.fetch_user(CFG_ON, "tok123")
|
|
||||||
assert profile == {
|
|
||||||
"login": "alice",
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar_url": "https://git.example.com/avatars/alice.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_code_raises_when_no_access_token():
|
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 200
|
|
||||||
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
with pytest.raises(oauth.OAuthError):
|
|
||||||
await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_user_raises_on_error_status():
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status = 401
|
|
||||||
mock_response.text = AsyncMock(return_value="unauthorized")
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_response),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
|
||||||
__aenter__=AsyncMock(return_value=mock_session),
|
|
||||||
__aexit__=AsyncMock(return_value=False),
|
|
||||||
)):
|
|
||||||
with pytest.raises(oauth.OAuthError):
|
|
||||||
await oauth.fetch_user(CFG_ON, "tok123")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Integration-style tests: callback logic chain
|
# Integration-style tests: callback logic chain
|
||||||
@@ -276,13 +140,12 @@ async def test_callback_invalid_state_rejects():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_oauth_flow_chain():
|
async def test_full_oauth_flow_chain():
|
||||||
"""Integration-style test: state → exchange → fetch → provision chain."""
|
"""Integration-style test: state → exchange → fetch → provision chain."""
|
||||||
|
p = _gitea_provider()
|
||||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
||||||
|
|
||||||
# Step 1: create a state token
|
|
||||||
state = oauth.make_state()
|
state = oauth.make_state()
|
||||||
assert oauth.validate_state(state) is True # consumed; replay would return False
|
assert oauth.validate_state(state) is True
|
||||||
|
|
||||||
# Step 2: exchange code → token (mocked)
|
|
||||||
mock_token_response = AsyncMock()
|
mock_token_response = AsyncMock()
|
||||||
mock_token_response.status = 200
|
mock_token_response.status = 200
|
||||||
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
|
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
|
||||||
@@ -309,16 +172,431 @@ async def test_full_oauth_flow_chain():
|
|||||||
__aenter__=AsyncMock(return_value=mock_session),
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
__aexit__=AsyncMock(return_value=False),
|
__aexit__=AsyncMock(return_value=False),
|
||||||
)):
|
)):
|
||||||
token = await oauth.exchange_code(CFG_ON, "authcode", redirect_uri)
|
token = await oauth.exchange_code(p, "authcode", redirect_uri)
|
||||||
profile = await oauth.fetch_user(CFG_ON, token)
|
profile = await oauth.fetch_user(p, token)
|
||||||
|
|
||||||
assert token == "flow_token"
|
assert token == "flow_token"
|
||||||
assert profile["login"] == "flowuser"
|
assert profile["login"] == "flowuser"
|
||||||
|
|
||||||
# Step 3: provision user
|
|
||||||
_reset_users()
|
_reset_users()
|
||||||
user = users_mod.provision_oauth_user(
|
user = users_mod.provision_oauth_user(
|
||||||
profile["login"], profile["full_name"], profile["avatar_url"]
|
profile["login"], profile["full_name"], profile["avatar_url"]
|
||||||
)
|
)
|
||||||
assert user.username == "flowuser"
|
assert user.username == "flowuser"
|
||||||
assert user.check_password("anything") is False
|
assert user.check_password("anything") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_providers()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CFG_GITHUB = {
|
||||||
|
"oauth": {
|
||||||
|
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CFG_NEXTCLOUD = {
|
||||||
|
"oauth": {
|
||||||
|
"nc": {
|
||||||
|
"type": "nextcloud",
|
||||||
|
"url": "https://nc.example.com",
|
||||||
|
"client_id": "ncid",
|
||||||
|
"client_secret": "ncs",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CFG_MULTI = {
|
||||||
|
"oauth": {
|
||||||
|
"mygitea": {
|
||||||
|
"type": "gitea",
|
||||||
|
"url": "https://git.example.com",
|
||||||
|
"client_id": "cid",
|
||||||
|
"client_secret": "cs",
|
||||||
|
"label": "Work Gitea",
|
||||||
|
"logo": "https://example.com/logo.png",
|
||||||
|
},
|
||||||
|
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
|
||||||
|
"nc": {
|
||||||
|
"type": "nextcloud",
|
||||||
|
"url": "https://nc.example.com",
|
||||||
|
"client_id": "ncid",
|
||||||
|
"client_secret": "ncs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_backward_compat_no_type_field():
|
||||||
|
"""Old config without 'type' defaults to gitea."""
|
||||||
|
providers = oauth.get_providers(CFG_ON)
|
||||||
|
assert len(providers) == 1
|
||||||
|
p = providers[0]
|
||||||
|
assert p.name == "gitea"
|
||||||
|
assert p.type == "gitea"
|
||||||
|
assert p.label == "Gitea"
|
||||||
|
assert p.client_id == "cid"
|
||||||
|
assert p.authorize_url == "https://git.example.com/login/oauth/authorize"
|
||||||
|
assert p.token_url == "https://git.example.com/login/oauth/access_token"
|
||||||
|
assert p.profile_url == "https://git.example.com/api/v1/user"
|
||||||
|
assert p.scope == "user:email"
|
||||||
|
assert p.profile_data_path == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_multiple():
|
||||||
|
providers = oauth.get_providers(CFG_MULTI)
|
||||||
|
assert len(providers) == 3
|
||||||
|
names = [p.name for p in providers]
|
||||||
|
assert "mygitea" in names
|
||||||
|
assert "github" in names
|
||||||
|
assert "nc" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_custom_label_and_logo():
|
||||||
|
providers = oauth.get_providers(CFG_MULTI)
|
||||||
|
gitea = next(p for p in providers if p.name == "mygitea")
|
||||||
|
assert gitea.label == "Work Gitea"
|
||||||
|
assert gitea.logo == "https://example.com/logo.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_default_label():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
assert providers[0].label == "GitHub"
|
||||||
|
assert providers[0].logo == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_fixed_urls():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
p = providers[0]
|
||||||
|
assert p.authorize_url == "https://github.com/login/oauth/authorize"
|
||||||
|
assert p.token_url == "https://github.com/login/oauth/access_token"
|
||||||
|
assert p.profile_url == "https://api.github.com/user"
|
||||||
|
assert p.scope == "read:user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_nextcloud_urls_and_path():
|
||||||
|
providers = oauth.get_providers(CFG_NEXTCLOUD)
|
||||||
|
p = providers[0]
|
||||||
|
assert p.authorize_url == "https://nc.example.com/apps/oauth2/authorize"
|
||||||
|
assert p.token_url == "https://nc.example.com/apps/oauth2/api/v1/token"
|
||||||
|
assert p.profile_url == "https://nc.example.com/ocs/v2.php/cloud/user?format=json"
|
||||||
|
assert p.profile_data_path == ["ocs", "data"]
|
||||||
|
assert p.scope == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_client_id(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "missing" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_client_secret(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_id": "cid"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "missing" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_url_for_gitea(caplog):
|
||||||
|
cfg = {"oauth": {"gitea": {"type": "gitea", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "url" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_missing_url_for_nextcloud(caplog):
|
||||||
|
cfg = {"oauth": {"nc": {"type": "nextcloud", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "url" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_github_no_url_required():
|
||||||
|
providers = oauth.get_providers(CFG_GITHUB)
|
||||||
|
assert len(providers) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_skips_unknown_type(caplog):
|
||||||
|
cfg = {"oauth": {"mystery": {"type": "saml", "client_id": "cid", "client_secret": "cs"}}}
|
||||||
|
import logging
|
||||||
|
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
|
||||||
|
result = oauth.get_providers(cfg)
|
||||||
|
assert result == []
|
||||||
|
assert "saml" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_providers_empty_config():
|
||||||
|
assert oauth.get_providers({}) == []
|
||||||
|
assert oauth.get_providers(CFG_OFF) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_auth_url / exchange_code / fetch_user (generic, ResolvedProvider-based)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _gitea_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_ON)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _github_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_GITHUB)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _nextcloud_provider() -> oauth.ResolvedProvider:
|
||||||
|
return oauth.get_providers(CFG_NEXTCLOUD)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_gitea():
|
||||||
|
p = _gitea_provider()
|
||||||
|
url = oauth.build_auth_url(p, "teststate", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
assert parsed.netloc == "git.example.com"
|
||||||
|
assert parsed.path == "/login/oauth/authorize"
|
||||||
|
assert qs["client_id"] == ["cid"]
|
||||||
|
assert qs["state"] == ["teststate"]
|
||||||
|
assert qs["scope"] == ["user:email"]
|
||||||
|
assert qs["response_type"] == ["code"]
|
||||||
|
assert qs["redirect_uri"] == ["https://hbd.example.com/login/oauth/gitea/callback"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_github():
|
||||||
|
p = _github_provider()
|
||||||
|
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/github/callback")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
assert parsed.netloc == "github.com"
|
||||||
|
assert qs["scope"] == ["read:user"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_auth_url_nextcloud_no_scope_param():
|
||||||
|
"""Nextcloud scope is empty — the 'scope' key must be absent from the URL."""
|
||||||
|
p = _nextcloud_provider()
|
||||||
|
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/nc/callback")
|
||||||
|
qs = parse_qs(urlparse(url).query)
|
||||||
|
assert "scope" not in qs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_generic_returns_token():
|
||||||
|
p = _gitea_provider()
|
||||||
|
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
token = await oauth.exchange_code(p, "mycode", redirect_uri)
|
||||||
|
assert token == "tok123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_sends_accept_json():
|
||||||
|
"""Accept: application/json must be present for all providers (required by GitHub)."""
|
||||||
|
p = _github_provider()
|
||||||
|
captured_headers = {}
|
||||||
|
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"access_token": "ghtoken"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
|
||||||
|
def capture_post(url, **kwargs):
|
||||||
|
captured_headers.update(kwargs.get("headers", {}))
|
||||||
|
return AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session.post = capture_post
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
await oauth.exchange_code(p, "code", "https://hbd.example.com/login/oauth/github/callback")
|
||||||
|
|
||||||
|
assert captured_headers.get("Accept") == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_raises_on_error_status():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(return_value="unauthorized")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
with pytest.raises(oauth.OAuthError):
|
||||||
|
await oauth.exchange_code(p, "badcode", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_raises_when_no_access_token():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
with pytest.raises(oauth.OAuthError):
|
||||||
|
await oauth.exchange_code(p, "mycode", "https://hbd.example.com/login/oauth/gitea/callback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_gitea_returns_profile():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={
|
||||||
|
"login": "alice",
|
||||||
|
"full_name": "Alice Smith",
|
||||||
|
"avatar_url": "https://git.example.com/avatars/alice.png",
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
profile = await oauth.fetch_user(p, "tok123")
|
||||||
|
|
||||||
|
assert profile == {
|
||||||
|
"login": "alice",
|
||||||
|
"full_name": "Alice Smith",
|
||||||
|
"avatar_url": "https://git.example.com/avatars/alice.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_github_maps_name_field():
|
||||||
|
p = _github_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={
|
||||||
|
"login": "bobgh",
|
||||||
|
"name": "Bob GitHub",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1",
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
profile = await oauth.fetch_user(p, "ghtoken")
|
||||||
|
|
||||||
|
assert profile["login"] == "bobgh"
|
||||||
|
assert profile["full_name"] == "Bob GitHub"
|
||||||
|
assert profile["avatar_url"] == "https://avatars.githubusercontent.com/u/1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_nextcloud_nested_extraction():
|
||||||
|
"""Nextcloud profile is nested under ocs.data; avatar is absent."""
|
||||||
|
p = _nextcloud_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={
|
||||||
|
"ocs": {
|
||||||
|
"meta": {"status": "ok", "statuscode": 200},
|
||||||
|
"data": {
|
||||||
|
"id": "ncuser",
|
||||||
|
"display-name": "NC User",
|
||||||
|
"email": "nc@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
profile = await oauth.fetch_user(p, "nctoken")
|
||||||
|
|
||||||
|
assert profile["login"] == "ncuser"
|
||||||
|
assert profile["full_name"] == "NC User"
|
||||||
|
assert profile["avatar_url"] == "" # Nextcloud has no avatar field
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_user_raises_on_error_status():
|
||||||
|
p = _gitea_provider()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(return_value="unauthorized")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)):
|
||||||
|
with pytest.raises(oauth.OAuthError):
|
||||||
|
await oauth.fetch_user(p, "badtoken")
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_with_valid_provider():
|
||||||
|
assert oauth.is_enabled(CFG_ON) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_false_when_no_providers():
|
||||||
|
assert oauth.is_enabled(CFG_OFF) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled_false_partial_config():
|
||||||
|
assert oauth.is_enabled(CFG_PARTIAL) is False
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import pytest
|
||||||
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
|
CFG = {
|
||||||
|
"hbd_port": 50004,
|
||||||
|
"interval": 20,
|
||||||
|
"grace": 2,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice Smith", "admin": True, "password": "pbkdf2:sha256:abc",
|
||||||
|
"notification_channels": ["pushover_ops"]},
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"gitea": {"type": "gitea", "url": "https://git.example.com",
|
||||||
|
"client_id": "cid", "client_secret": "csec", "label": "Sign in with Gitea"},
|
||||||
|
},
|
||||||
|
"notification_channels": {
|
||||||
|
"pushover_ops": {"type": "pushover", "token": "tok", "user": "usr"},
|
||||||
|
},
|
||||||
|
"hosts": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sections_have_section_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
for s in sections:
|
||||||
|
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
||||||
|
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sections_have_api_section():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
for s in sections:
|
||||||
|
assert "api_section" in s, f"Section {s['id']} missing api_section"
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_section_has_editable_fields():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
network = next(s for s in sections if s["id"] == "network")
|
||||||
|
assert network["section_mode"] == "form"
|
||||||
|
assert network["api_section"] == "server"
|
||||||
|
editable = [f for f in network["fields"] if f["editable"]]
|
||||||
|
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_sections_have_correct_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
||||||
|
assert "channels" not in yaml_sections # now uses "channels" mode
|
||||||
|
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
||||||
|
assert "thresholds" in yaml_sections
|
||||||
|
assert "dns" in yaml_sections
|
||||||
|
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
||||||
|
assert yaml_sections["dns"]["api_section"] == "dns"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hosts_section_uses_hosts_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
hosts_sec = next(s for s in sections if s["id"] == "hosts")
|
||||||
|
assert hosts_sec["section_mode"] == "hosts"
|
||||||
|
assert hosts_sec["api_section"] == "hosts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channels_section_uses_channels_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
ch_sec = next(s for s in sections if s["id"] == "channels")
|
||||||
|
assert ch_sec["section_mode"] == "channels"
|
||||||
|
assert ch_sec["api_section"] == "notification_channels"
|
||||||
|
assert len(ch_sec["channels"]) == 1
|
||||||
|
ch = ch_sec["channels"][0]
|
||||||
|
assert ch["name"] == "pushover_ops"
|
||||||
|
assert ch["type"] == "pushover"
|
||||||
|
assert "owner" in ch
|
||||||
|
assert "private" in ch
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_type_schemas_exported():
|
||||||
|
assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS")
|
||||||
|
for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"):
|
||||||
|
assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS
|
||||||
|
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type]
|
||||||
|
assert "label" in schema
|
||||||
|
assert "fields" in schema
|
||||||
|
for f in schema["fields"]:
|
||||||
|
assert "key" in f
|
||||||
|
assert "type" in f
|
||||||
|
assert "required" in f
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_section_exists():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
||||||
|
assert oauth is not None
|
||||||
|
assert oauth["section_mode"] == "form"
|
||||||
|
assert oauth["api_section"] == "oauth"
|
||||||
|
assert len(oauth["providers"]) == 1
|
||||||
|
assert oauth["providers"][0]["name"] == "gitea"
|
||||||
|
assert oauth["providers"][0]["client_secret"] == "•••"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_channel_names_returned():
|
||||||
|
result = settings_mod.get_settings_data(CFG)
|
||||||
|
assert "all_channel_names" in result
|
||||||
|
assert "pushover_ops" in result["all_channel_names"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_section_has_user_list():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
users_sec = next(s for s in sections if s["id"] == "users")
|
||||||
|
assert users_sec["section_mode"] == "form"
|
||||||
|
assert users_sec["api_section"] == "users"
|
||||||
|
assert len(users_sec["users"]) == 1
|
||||||
|
assert users_sec["users"][0]["username"] == "alice"
|
||||||
|
# Password hash never exposed
|
||||||
|
assert "password" not in users_sec["users"][0]
|
||||||
Reference in New Issue
Block a user