Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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/
|
||||||
@@ -13,3 +14,5 @@ ssl/
|
|||||||
uv.lock
|
uv.lock
|
||||||
.hb.yaml
|
.hb.yaml
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
rndc-key
|
||||||
|
docs/superpowers/
|
||||||
|
|||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here, organized by release.
|
||||||
|
|
||||||
|
## [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 ✓
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,210 +0,0 @@
|
|||||||
# Config Editor — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-05-09
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow admins to edit the full `.hb.yaml` config through the Settings page UI, and allow regular users to manage their own notification channels and profile fields through the Profile page. The YAML file remains the single authoritative source; comments are preserved on every write.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser (admin) Browser (user)
|
|
||||||
staged edits (JS state) form fields
|
|
||||||
│ │
|
|
||||||
│ POST /api/0/config │ PUT /api/0/users/me
|
|
||||||
▼ ▼
|
|
||||||
http.py handlers ────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
configio.py ←── ruamel.yaml (round-trip, comment-preserving)
|
|
||||||
│
|
|
||||||
├── backup .hb.yaml.bak.YYYYMMDD-HHMMSS (keep last 10)
|
|
||||||
├── write atomically (temp file → os.replace)
|
|
||||||
└── ReloadableConfig.reload()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Dependency
|
|
||||||
|
|
||||||
Add `ruamel.yaml>=0.18` to `[project.optional-dependencies] server` in `pyproject.toml`. `PyYAML` stays (used by the client and config loader for reads); `ruamel.yaml` is used only for write-back.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Module: `hbd/server/configio.py`
|
|
||||||
|
|
||||||
Single responsibility: all YAML read/write for `.hb.yaml`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
_write_lock = threading.Lock()
|
|
||||||
|
|
||||||
def read_roundtrip(path: str) -> CommentedMap:
|
|
||||||
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
|
|
||||||
|
|
||||||
def write_config(path: str, data: CommentedMap) -> 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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list_backups(path: str) -> list[str]:
|
|
||||||
"""Return backup paths sorted newest-first."""
|
|
||||||
|
|
||||||
def apply_structured_section(data: CommentedMap, section: str, values: dict) -> None:
|
|
||||||
"""Merge a dict of scalar/list values into data[section], key by key.
|
|
||||||
Preserves comments on unmodified keys.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def apply_yaml_section(data: CommentedMap, section: str, yaml_text: str) -> None:
|
|
||||||
"""Replace data[section] entirely by parsing yaml_text.
|
|
||||||
Used for YAML-editor sections (notification_channels, thresholds, hosts, dns).
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
All endpoints require authentication. Admin-only endpoints return 403 for non-admins.
|
|
||||||
|
|
||||||
| Method | Path | Auth | Purpose |
|
|
||||||
|--------|------|------|---------|
|
|
||||||
| GET | `/api/0/config` | admin | Full config as JSON (secrets masked) |
|
|
||||||
| POST | `/api/0/config` | admin | Publish staged changes to `.hb.yaml` |
|
|
||||||
| GET | `/api/0/config/section/{name}` | admin | Raw YAML text for one section (for YAML editors) |
|
|
||||||
| GET | `/api/0/config/backups` | admin | List of backup timestamps, newest first |
|
|
||||||
| POST | `/api/0/config/rollback` | admin | `{"backup": "…"}` → restore backup and reload |
|
|
||||||
| PUT | `/api/0/users/me` | any user | Update own `full_name`, `avatar`, `notification_channels`, `password` |
|
|
||||||
|
|
||||||
### `POST /api/0/config` payload
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server": { "hbd_port": 50004, "interval": 20, ... },
|
|
||||||
"users": { "alice": { "full_name": "Alice", "admin": true, ... }, ... },
|
|
||||||
"oauth": { "gitea": { "type": "gitea", "url": "...", ... }, ... },
|
|
||||||
"notification_channels": "<raw yaml text>",
|
|
||||||
"thresholds": "<raw yaml text>",
|
|
||||||
"hosts": "<raw yaml text>",
|
|
||||||
"dns": "<raw yaml text>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Only sections present in the payload are updated; omitted sections are left unchanged in the file.
|
|
||||||
|
|
||||||
**Section-to-key mapping:** Most config fields are top-level keys in `.hb.yaml` (not nested under a section key). The API uses logical section names that map to specific top-level keys:
|
|
||||||
|
|
||||||
| Logical section | Top-level YAML keys covered |
|
|
||||||
|---|---|
|
|
||||||
| `server` | `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` |
|
|
||||||
| `users` | `users` (top-level dict) |
|
|
||||||
| `oauth` | `oauth` (top-level dict) |
|
|
||||||
| `notification_channels` | `notification_channels` (top-level dict, YAML text) |
|
|
||||||
| `thresholds` | `threshold_configs` (top-level dict if present, YAML text) |
|
|
||||||
| `hosts` | `hosts` (top-level dict, YAML text) |
|
|
||||||
| `dns` | `nsupdate_bin`, `dyndomains`, `dyndnshosts`, `drophosts` (YAML text of just these keys) |
|
|
||||||
|
|
||||||
`apply_structured_section` for `server` iterates the known key list and updates each present key individually, preserving comments on unchanged keys. `apply_yaml_section` for dict-valued sections (notification_channels, hosts, oauth) replaces the entire subtree. For `dns`, it replaces each of the four top-level keys listed.
|
|
||||||
|
|
||||||
### `PUT /api/0/users/me` payload
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"full_name": "Alice Smith",
|
|
||||||
"avatar": "/avatars/alice.png",
|
|
||||||
"notification_channels": ["pushover_ops", "matrix_alerts"],
|
|
||||||
"password": { "current": "oldpass", "new": "newpass" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional. `password` change requires `current` to match; server re-hashes with PBKDF2-HMAC-SHA256 before writing. Both `full_name`/`avatar`/`notification_channels` and password can be sent in one request or separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings Page Changes (`/settings`)
|
|
||||||
|
|
||||||
### Section split
|
|
||||||
|
|
||||||
| Section | Edit mode | Notes |
|
|
||||||
|---------|-----------|-------|
|
|
||||||
| Server settings | Form | Scalar fields: ports, intervals, base_url, grace, renotify interval, log/pid/pickle paths, journal settings |
|
|
||||||
| Users | Form | CRUD list: add/edit/delete users; fields: username, full_name, avatar, admin toggle, notification_channels multiselect. Password field: leave blank to keep existing hash; enter a new plain-text password to replace it (server hashes before writing). New users require a password. |
|
|
||||||
| OAuth providers | Form | CRUD list: add/edit/delete providers; fields: name (slug), type, url, client_id, client_secret, label, logo |
|
|
||||||
| Notification channels | YAML editor | Too many provider-specific credential shapes for typed forms |
|
|
||||||
| Thresholds | YAML editor | Complex nested rules |
|
|
||||||
| Hosts | YAML editor | Complex per-host config |
|
|
||||||
| DNS / DynDNS | YAML editor | nsupdate settings, dyndomains, drophosts |
|
|
||||||
|
|
||||||
### Publish flow
|
|
||||||
|
|
||||||
1. Each section has a **"Stage changes"** button. Clicking it stores that section's current form/editor values in browser JS state. A banner appears: *"N pending changes — not yet saved to .hb.yaml"*.
|
|
||||||
2. **"Publish to .hb.yaml"** sends `POST /api/0/config` with all staged sections.
|
|
||||||
3. On success: banner clears, page reloads to show current saved state.
|
|
||||||
4. **"Discard all"** clears JS state and reloads from server without writing.
|
|
||||||
|
|
||||||
### Rollback UI
|
|
||||||
|
|
||||||
A "View backups / rollback" link at the bottom of the settings sidebar opens a modal listing available backups (timestamp + approximate age). Clicking a backup shows a confirmation prompt before calling `POST /api/0/config/rollback`.
|
|
||||||
|
|
||||||
### `settings.py` changes
|
|
||||||
|
|
||||||
- Set `"editable": True` on all fields that now have form inputs.
|
|
||||||
- The existing field descriptor structure (`key`, `type`, `label`, `value`, `sensitive`) is already designed for this — no structural changes needed.
|
|
||||||
- Add `"section_mode": "form" | "yaml"` per section, used by the template to render the appropriate editor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Profile Page Changes (`/profile`)
|
|
||||||
|
|
||||||
New editable fields alongside the existing read-only display:
|
|
||||||
|
|
||||||
**Identity card** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Display name — text input, current `full_name`
|
|
||||||
- Avatar — text input, current `avatar` URL or path
|
|
||||||
- Save button → immediate write, no publish step
|
|
||||||
|
|
||||||
**Change password** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Current password, new password inputs
|
|
||||||
- Save button → validates current password server-side, re-hashes new password, writes
|
|
||||||
|
|
||||||
**Notification channels** (saves via `PUT /api/0/users/me`):
|
|
||||||
- Checkbox list of all globally-defined channels (from `config["notification_channels"]`)
|
|
||||||
- Shows channel type and `min_level` as secondary text
|
|
||||||
- Pre-checked based on user's current `notification_channels` list
|
|
||||||
- Save button → writes user's channel list immediately
|
|
||||||
|
|
||||||
Host access list remains read-only (existing behaviour).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Write Safety
|
|
||||||
|
|
||||||
- `configio._write_lock` serializes all writes (admin publish and user self-service can race if multiple requests arrive simultaneously).
|
|
||||||
- All writes are atomic: temp file written in same directory as `.hb.yaml`, then `os.replace()`. A crash mid-write leaves the backup intact and the original file unchanged.
|
|
||||||
- If `.hb.yaml` cannot be written (permissions, disk full), the API returns `500` with an error message; no partial write occurs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Secrets Handling
|
|
||||||
|
|
||||||
- `GET /api/0/config` masks sensitive fields (passwords, tokens, API keys) with `"•••"` — same logic as the existing read-only settings page.
|
|
||||||
- `GET /api/0/config/section/{name}` for YAML-editor sections returns the raw YAML text including real credential values, since the admin needs to edit them. This endpoint requires admin auth and must only be served over HTTPS in production.
|
|
||||||
- Secrets in backups are unmasked (they are copies of the real file). Backup directory should have the same file permissions as `.hb.yaml` itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Conflict detection if `.hb.yaml` is modified externally between page load and publish (the last write wins; the previous state is always recoverable from a backup)
|
|
||||||
- Multi-admin concurrent edit awareness
|
|
||||||
- Config validation UI beyond what the server returns as errors
|
|
||||||
- Diff view before publish
|
|
||||||
- Audit log of who published what (beyond the event log entry already added for login/logout)
|
|
||||||
- Per-host threshold editing via UI (thresholds section uses YAML editor)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Multi-Provider OAuth2 — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-05-09
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow multiple OAuth2 providers to be configured simultaneously. All enabled providers appear as login buttons on the login panel. Supported provider types: Gitea, GitHub, Nextcloud. Existing single-Gitea configs continue to work without changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Config Format
|
|
||||||
|
|
||||||
Each entry in the `oauth` dict is a named provider instance. The dict key becomes the route slug.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oauth:
|
|
||||||
work-gitea: # /login/oauth/work-gitea
|
|
||||||
type: gitea # optional — defaults to "gitea" when absent (backward compat)
|
|
||||||
url: https://git.example.com
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
label: "Work Gitea" # optional display name; falls back to provider default
|
|
||||||
logo: https://… # optional logo URL for button
|
|
||||||
github:
|
|
||||||
type: github # no url needed — fixed SaaS endpoints
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
nextcloud:
|
|
||||||
type: nextcloud
|
|
||||||
url: https://cloud.example.com
|
|
||||||
client_id: xxx
|
|
||||||
client_secret: yyy
|
|
||||||
```
|
|
||||||
|
|
||||||
**Backward compatibility:** The existing `oauth.gitea.{url,client_id,client_secret}` config (no `type` field) is treated as `type: gitea`. No migration required.
|
|
||||||
|
|
||||||
**Validation:** Entries missing `client_id`, `client_secret`, or `url` (when the provider type requires it) are skipped with a warning log. This prevents a misconfigured entry from disabling all OAuth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider Registry (`oauth.py`)
|
|
||||||
|
|
||||||
A `PROVIDER_DEFS` dict holds static knowledge about each supported provider type:
|
|
||||||
|
|
||||||
| | gitea | github | nextcloud |
|
|
||||||
|---|---|---|---|
|
|
||||||
| authorize URL | `{url}/login/oauth/authorize` | `https://github.com/login/oauth/authorize` | `{url}/apps/oauth2/authorize` |
|
|
||||||
| token URL | `{url}/login/oauth/access_token` | `https://github.com/login/oauth/access_token` | `{url}/apps/oauth2/api/v1/token` |
|
|
||||||
| profile URL | `{url}/api/v1/user` | `https://api.github.com/user` | `{url}/ocs/v2.php/cloud/user?format=json` |
|
|
||||||
| scope | `user:email` | `read:user` | *(empty)* |
|
|
||||||
| username field | `login` | `login` | nested: `ocs.data.id` |
|
|
||||||
| display name field | `full_name` | `name` | nested: `ocs.data.display-name` |
|
|
||||||
| avatar field | `avatar_url` | `avatar_url` | *(absent — left empty)* |
|
|
||||||
| requires `url` | yes | no | yes |
|
|
||||||
| default label | `Gitea` | `GitHub` | `Nextcloud` |
|
|
||||||
|
|
||||||
Nextcloud's profile response is nested (`ocs → data`). The registry entry includes a `profile_data_path: ["ocs", "data"]` that is navigated before field extraction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New / Changed API in `oauth.py`
|
|
||||||
|
|
||||||
### `ResolvedProvider` (new dataclass)
|
|
||||||
|
|
||||||
All endpoint URLs are pre-computed strings (no more template substitution at call time):
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class ResolvedProvider:
|
|
||||||
name: str # route slug (dict key)
|
|
||||||
type: str # "gitea" | "github" | "nextcloud"
|
|
||||||
label: str # display name for login button
|
|
||||||
logo: str # URL or ""
|
|
||||||
authorize_url: str
|
|
||||||
token_url: str
|
|
||||||
profile_url: str
|
|
||||||
scope: str
|
|
||||||
client_id: str
|
|
||||||
client_secret: str
|
|
||||||
field_map: dict # {"username": "<provider_field>", "full_name": ..., "avatar": ...}
|
|
||||||
profile_data_path: list[str] # e.g. ["ocs", "data"] or []
|
|
||||||
```
|
|
||||||
|
|
||||||
### `get_providers(config) → list[ResolvedProvider]` (new)
|
|
||||||
|
|
||||||
Iterates `config.get("oauth", {})`, resolves each valid entry against `PROVIDER_DEFS`, skips invalid entries. Returns providers in config declaration order (determines button order on login page).
|
|
||||||
|
|
||||||
### `build_auth_url(provider, state, redirect_uri)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. Uses `provider.authorize_url`, `provider.scope`, `provider.client_id`.
|
|
||||||
|
|
||||||
### `exchange_code(provider, code, redirect_uri)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. Sets `Accept: application/json` on all token requests (required for GitHub, harmless for others).
|
|
||||||
|
|
||||||
### `fetch_user(provider, access_token)` (updated signature)
|
|
||||||
|
|
||||||
Takes a `ResolvedProvider`. After fetching the profile JSON, navigates `provider.profile_data_path` before applying `provider.field_map`. Missing fields (e.g., Nextcloud avatar) are mapped to `""`.
|
|
||||||
|
|
||||||
### `is_enabled(config)` (updated)
|
|
||||||
|
|
||||||
Returns `True` if `get_providers(config)` returns at least one provider.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes (`http.py`)
|
|
||||||
|
|
||||||
Replace the two hardcoded Gitea routes with generic ones:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /login/oauth/{name} initiate OAuth flow
|
|
||||||
GET /login/oauth/{name}/callback receive code, provision user, set session
|
|
||||||
```
|
|
||||||
|
|
||||||
Both handlers resolve `{name}` via `get_providers(config)`. If the name is not found, return 404. Existing `/login/oauth/gitea` URLs continue to work as long as the config has a `gitea` key.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Login Page (`http.py`)
|
|
||||||
|
|
||||||
The "or" divider appears once if any providers are configured. Below it, one button per provider stacks vertically. Button appearance mirrors the current Gitea button (same CSS class, optional logo img). Button `href` is `/login/oauth/{provider.name}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests (`tests/test_oauth.py`)
|
|
||||||
|
|
||||||
**Updated:** Existing tests for `build_auth_url`, `exchange_code`, `fetch_user`, `is_enabled` ported to new `ResolvedProvider`-based signatures.
|
|
||||||
|
|
||||||
**New:**
|
|
||||||
- `get_providers()` with old single-Gitea config (no `type`) → one provider, backward compat confirmed
|
|
||||||
- `get_providers()` with Gitea + GitHub + Nextcloud → correct count, types, and labels
|
|
||||||
- `get_providers()` skips entry missing `client_id` or `client_secret`
|
|
||||||
- `get_providers()` skips Gitea/Nextcloud entry missing `url`
|
|
||||||
- `get_providers()` skips entry with unknown `type` (logs warning)
|
|
||||||
- `build_auth_url` for each provider type → correct authorize URL
|
|
||||||
- `exchange_code` for GitHub → `Accept: application/json` header present
|
|
||||||
- `fetch_user` for Nextcloud → `ocs.data` navigation, missing avatar handled as `""`
|
|
||||||
- Login page HTML → one button per provider; no buttons when `oauth` is empty
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Generic/custom provider with user-specified endpoints
|
|
||||||
- OIDC / token introspection
|
|
||||||
- Restricting login to specific GitHub orgs or Nextcloud groups
|
|
||||||
- Automatic admin promotion from OAuth
|
|
||||||
- Token refresh
|
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.9"
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+26
-36
@@ -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):
|
||||||
|
|||||||
+23
-1
@@ -21,10 +21,11 @@ _SERVER_KEYS = [
|
|||||||
"interval", "grace", "base_url", "threshold_renotify_interval",
|
"interval", "grace", "base_url", "threshold_renotify_interval",
|
||||||
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
|
||||||
"journal_max_size", "journal_max_backups", "default_owner",
|
"journal_max_size", "journal_max_backups", "default_owner",
|
||||||
|
"default_threshold_config",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Top-level keys managed by the 'dns' logical section
|
# Top-level keys managed by the 'dns' logical section
|
||||||
_DNS_KEYS = ["nsupdate_bin", "dyndomains", "dyndnshosts", "drophosts"]
|
_DNS_KEYS = ["nsupdate_bin", "rndc_key", "dyndomains"]
|
||||||
|
|
||||||
|
|
||||||
def read_roundtrip(path: str):
|
def read_roundtrip(path: str):
|
||||||
@@ -87,12 +88,33 @@ def apply_structured_section(data, section: str, values: dict) -> None:
|
|||||||
for key in _SERVER_KEYS:
|
for key in _SERVER_KEYS:
|
||||||
if key in values:
|
if key in values:
|
||||||
data[key] = values[key]
|
data[key] = values[key]
|
||||||
|
elif section == "dns":
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
if key in values:
|
||||||
|
data[key] = values[key]
|
||||||
|
else:
|
||||||
|
data.pop(key, None)
|
||||||
elif section == "users":
|
elif section == "users":
|
||||||
data["users"] = values
|
data["users"] = values
|
||||||
|
elif section == "hosts":
|
||||||
|
data["hosts"] = values
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown structured section: {section!r}")
|
raise ValueError(f"Unknown structured section: {section!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_channel(data, name: str, channel_cfg: dict) -> None:
|
||||||
|
"""Insert or replace a single notification channel entry, preserving others."""
|
||||||
|
if not data.get("notification_channels"):
|
||||||
|
data["notification_channels"] = {}
|
||||||
|
data["notification_channels"][name] = channel_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def delete_channel(data, name: str) -> None:
|
||||||
|
"""Remove a notification channel by name. No-op if not found."""
|
||||||
|
nc = data.get("notification_channels") or {}
|
||||||
|
nc.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
|
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
|
||||||
"""Replace the named logical section by parsing yaml_text."""
|
"""Replace the named logical section by parsing yaml_text."""
|
||||||
parsed = _make_yaml().load(yaml_text)
|
parsed = _make_yaml().load(yaml_text)
|
||||||
|
|||||||
+18
-15
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
from subprocess import Popen, PIPE, STDOUT
|
from subprocess import Popen, PIPE, STDOUT
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_nsupdate_payload(
|
def create_nsupdate_payload(
|
||||||
@@ -123,7 +126,6 @@ async def dns_update_worker(
|
|||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = f"changed address to {addr}"
|
|
||||||
for dyndomain in cfg.get("dyndomains", []):
|
for dyndomain in cfg.get("dyndomains", []):
|
||||||
err = await loop.run_in_executor(
|
err = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -135,28 +137,29 @@ async def dns_update_worker(
|
|||||||
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
m += f", DNS update failed: {err}"
|
m = f"DNS update failed for {addr} ({dyndomain}): {err}"
|
||||||
logger.error("DNS update failed for %s: %s", name, err)
|
logger.error("DNS update failed for %s: %s", name, err)
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "ERROR", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
m += ", DNS updated."
|
m = f"DNS updated {name}.dy.{dyndomain} → {addr}"
|
||||||
|
if log:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, log, name, "INFO", m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not cfg.get("dyndomains"):
|
||||||
|
logger.warning("DNS update triggered for %s but no dyndomains configured", name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dnsq.task_done()
|
dnsq.task_done()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, name, m)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if log:
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def start_dns_worker(
|
def start_dns_worker(
|
||||||
hbdclass,
|
hbdclass,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+449
-11
@@ -25,6 +25,81 @@ 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)
|
||||||
@@ -126,6 +201,66 @@ def _mask_config_for_api(config) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||||
|
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||||
|
hbc_version = None
|
||||||
|
hbc_type = None
|
||||||
|
latest_os = host.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
_, os_data = latest_os
|
||||||
|
hbc_version = os_data.get("hbc_version")
|
||||||
|
hbc_type = os_data.get("hbc_type")
|
||||||
|
|
||||||
|
last_packet = None
|
||||||
|
if host.connections:
|
||||||
|
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||||
|
|
||||||
|
thresholds = None
|
||||||
|
if threshold_checker is not None:
|
||||||
|
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||||
|
|
||||||
|
# Build reverse coverage: which metric paths suffix-match to each threshold.
|
||||||
|
# Mirrors the logic in ThresholdChecker._find_threshold.
|
||||||
|
coverage: dict = {}
|
||||||
|
for plugin_name, samples in host.plugin_data.items():
|
||||||
|
if not samples:
|
||||||
|
continue
|
||||||
|
_, pdata = samples[-1]
|
||||||
|
for field_name in pdata:
|
||||||
|
full_path = f"{plugin_name}.{field_name}"
|
||||||
|
if full_path in raw:
|
||||||
|
continue # exact match — the threshold IS this metric
|
||||||
|
parts = field_name.split("_")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
candidate = f"{plugin_name}." + "_".join(parts[i:])
|
||||||
|
if candidate in raw:
|
||||||
|
coverage.setdefault(candidate, []).append(full_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
thresholds = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"metric": tc.metric_path,
|
||||||
|
"warning": tc.warning,
|
||||||
|
"critical": tc.critical,
|
||||||
|
"operator": tc.operator.value,
|
||||||
|
"covers": sorted(coverage.get(tc.metric_path, [])),
|
||||||
|
}
|
||||||
|
for tc in raw.values()
|
||||||
|
],
|
||||||
|
key=lambda x: x["metric"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": getattr(host, "owner", None),
|
||||||
|
"managers": list(getattr(host, "managers", [])),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
|
"hbc_type": hbc_type,
|
||||||
|
"last_packet": last_packet,
|
||||||
|
"thresholds": thresholds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -136,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.
|
||||||
|
|
||||||
@@ -189,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()}
|
||||||
@@ -459,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:
|
||||||
@@ -828,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
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -879,7 +1036,23 @@ async def start(
|
|||||||
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
||||||
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
||||||
|
|
||||||
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
# Build visible channels list for chip picker and My Channels management.
|
||||||
|
visible_channels = _visible_channels_for_user(current_user) if current_user else {}
|
||||||
|
all_channels = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"type": cfg.get("type", ""),
|
||||||
|
"owner": cfg.get("owner"),
|
||||||
|
"private": bool(cfg.get("private", False)),
|
||||||
|
}
|
||||||
|
for name, cfg in visible_channels.items()
|
||||||
|
if isinstance(cfg, dict)
|
||||||
|
],
|
||||||
|
key=lambda c: c["name"],
|
||||||
|
)
|
||||||
|
# Keep all_channel_names for backwards-compat with any template references.
|
||||||
|
all_channel_names = [c["name"] for c in all_channels]
|
||||||
|
|
||||||
tmpl = env.get_template("profile.html")
|
tmpl = env.get_template("profile.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
@@ -890,6 +1063,7 @@ async def start(
|
|||||||
managed_hosts=managed,
|
managed_hosts=managed,
|
||||||
monitored_hosts=monitored,
|
monitored_hosts=monitored,
|
||||||
notification_channels=notif_channels,
|
notification_channels=notif_channels,
|
||||||
|
all_channels=all_channels,
|
||||||
all_channel_names=all_channel_names,
|
all_channel_names=all_channel_names,
|
||||||
active_page="profile",
|
active_page="profile",
|
||||||
)
|
)
|
||||||
@@ -955,6 +1129,8 @@ async def start(
|
|||||||
title="Settings - Heartbeat",
|
title="Settings - Heartbeat",
|
||||||
sections=settings_data["sections"],
|
sections=settings_data["sections"],
|
||||||
all_channel_names=settings_data["all_channel_names"],
|
all_channel_names=settings_data["all_channel_names"],
|
||||||
|
all_usernames=settings_data["all_usernames"],
|
||||||
|
all_threshold_configs=settings_data["all_threshold_configs"],
|
||||||
current_user=current_user.to_dict() if current_user else None,
|
current_user=current_user.to_dict() if current_user else None,
|
||||||
active_page="settings",
|
active_page="settings",
|
||||||
)
|
)
|
||||||
@@ -1132,18 +1308,40 @@ async def start(
|
|||||||
attrs.pop("client_secret", None)
|
attrs.pop("client_secret", None)
|
||||||
data["oauth"] = new_oauth
|
data["oauth"] = new_oauth
|
||||||
|
|
||||||
for section in ("notification_channels", "thresholds", "hosts", "dns"):
|
if "notification_channels" in payload:
|
||||||
if section in payload:
|
configio_mod.apply_yaml_section(data, "notification_channels", payload["notification_channels"])
|
||||||
configio_mod.apply_yaml_section(data, section, payload[section])
|
|
||||||
|
if "dns" in payload:
|
||||||
|
dns_payload = payload["dns"]
|
||||||
|
if isinstance(dns_payload, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "dns", dns_payload)
|
||||||
|
else:
|
||||||
|
configio_mod.apply_structured_section(data, "dns", dns_payload)
|
||||||
|
|
||||||
|
if "thresholds" in payload:
|
||||||
|
tc = payload["thresholds"]
|
||||||
|
if isinstance(tc, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
|
elif isinstance(tc, dict):
|
||||||
|
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
||||||
|
|
||||||
|
if "hosts" in payload:
|
||||||
|
h = payload["hosts"]
|
||||||
|
if isinstance(h, dict):
|
||||||
|
configio_mod.apply_structured_section(data, "hosts", h)
|
||||||
|
else:
|
||||||
|
configio_mod.apply_yaml_section(data, "hosts", h)
|
||||||
|
|
||||||
configio_mod.write_config(_config_path, data)
|
configio_mod.write_config(_config_path, data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Config write failed: %s", exc)
|
logger.error("Config write failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1172,12 +1370,240 @@ async def start(
|
|||||||
logger.error("Rollback failed: %s", exc)
|
logger.error("Rollback failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Notification channel helpers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _visible_channels_for_user(user):
|
||||||
|
"""Return {name: cfg} of channels visible to user (public + own private)."""
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user is None:
|
||||||
|
return {}
|
||||||
|
if user.admin:
|
||||||
|
return dict(all_channels)
|
||||||
|
visible = {}
|
||||||
|
for name, cfg in all_channels.items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
if not cfg.get("private") or cfg.get("owner") == user.username:
|
||||||
|
visible[name] = cfg
|
||||||
|
return visible
|
||||||
|
|
||||||
|
def _build_channel_response(ch_name, ch_cfg):
|
||||||
|
"""Serialize a channel config dict for the API response."""
|
||||||
|
ch_type = ch_cfg.get("type", "")
|
||||||
|
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
|
||||||
|
fields = []
|
||||||
|
for sf in schema_fields:
|
||||||
|
k = sf["key"]
|
||||||
|
v = ch_cfg.get(k, "")
|
||||||
|
sensitive = sf["type"] == "secret"
|
||||||
|
fields.append({
|
||||||
|
"key": k,
|
||||||
|
"label": sf["label"],
|
||||||
|
"value": "•••" if (sensitive and v) else (
|
||||||
|
", ".join(v) if isinstance(v, list) else str(v or "")
|
||||||
|
),
|
||||||
|
"sensitive": sensitive,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"name": ch_name,
|
||||||
|
"type": ch_type,
|
||||||
|
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
|
"owner": ch_cfg.get("owner"),
|
||||||
|
"private": bool(ch_cfg.get("private", False)),
|
||||||
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Notification channel API (any authenticated user)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def api_notification_channel_types(request):
|
||||||
|
"""GET /api/0/notification_channel_types — channel type schemas."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS)
|
||||||
|
|
||||||
|
async def api_notification_channels_get(request):
|
||||||
|
"""GET /api/0/notification_channels — list channels visible to current user."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
visible = _visible_channels_for_user(user)
|
||||||
|
result = [
|
||||||
|
_build_channel_response(name, cfg)
|
||||||
|
for name, cfg in visible.items()
|
||||||
|
if isinstance(cfg, dict)
|
||||||
|
]
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
async def api_notification_channels_post(request):
|
||||||
|
"""POST /api/0/notification_channels — create a new channel."""
|
||||||
|
user, err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if user is None:
|
||||||
|
return web.json_response({"error": "Authentication required"}, status=401)
|
||||||
|
if not _config_path:
|
||||||
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
name = (body.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return web.json_response({"error": "Channel name is required"}, status=400)
|
||||||
|
ch_type = (body.get("type") or "").strip()
|
||||||
|
if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS:
|
||||||
|
return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400)
|
||||||
|
if name in (config.get("notification_channels") or {}):
|
||||||
|
return web.json_response({"error": f"Channel {name!r} already exists"}, status=409)
|
||||||
|
|
||||||
|
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type]
|
||||||
|
channel_cfg = {"type": ch_type}
|
||||||
|
for sf in schema["fields"]:
|
||||||
|
k = sf["key"]
|
||||||
|
v = body.get(k, "")
|
||||||
|
if v:
|
||||||
|
channel_cfg[k] = v
|
||||||
|
elif sf["required"]:
|
||||||
|
return web.json_response({"error": f"Field {k!r} is required"}, status=400)
|
||||||
|
|
||||||
|
if body.get("min_level"):
|
||||||
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
|
channel_cfg["owner"] = user.username
|
||||||
|
if body.get("private"):
|
||||||
|
channel_cfg["private"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
configio_mod.apply_channel(disk_data, name, channel_cfg)
|
||||||
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Channel create failed: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
if 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):
|
async def api_user_self_put(request):
|
||||||
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
|
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
@@ -1220,7 +1646,10 @@ async def start(
|
|||||||
if "avatar" in body:
|
if "avatar" in body:
|
||||||
user_entry["avatar"] = str(body["avatar"])
|
user_entry["avatar"] = str(body["avatar"])
|
||||||
if "notification_channels" in body:
|
if "notification_channels" in body:
|
||||||
user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]]
|
visible = _visible_channels_for_user(user)
|
||||||
|
user_entry["notification_channels"] = [
|
||||||
|
str(ch) for ch in body["notification_channels"] if ch in visible
|
||||||
|
]
|
||||||
if password_change:
|
if password_change:
|
||||||
user_entry["password"] = users_mod.hash_password(password_change["new"])
|
user_entry["password"] = users_mod.hash_password(password_change["new"])
|
||||||
|
|
||||||
@@ -1230,9 +1659,11 @@ async def start(
|
|||||||
logger.error("User self-update failed: %s", exc)
|
logger.error("User self-update failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
if hasattr(config, "reload"):
|
if reload_callback:
|
||||||
|
await reload_callback()
|
||||||
|
elif hasattr(config, "reload"):
|
||||||
await config.reload()
|
await config.reload()
|
||||||
users_mod.load_users(config)
|
users_mod.load_users(config)
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
@@ -1260,6 +1691,12 @@ async def start(
|
|||||||
web.get("/api/0/config/backups", api_config_backups_get),
|
web.get("/api/0/config/backups", api_config_backups_get),
|
||||||
web.post("/api/0/config", api_config_post),
|
web.post("/api/0/config", api_config_post),
|
||||||
web.post("/api/0/config/rollback", api_config_rollback),
|
web.post("/api/0/config/rollback", api_config_rollback),
|
||||||
|
# Notification channel API (any authenticated user)
|
||||||
|
web.get("/api/0/notification_channel_types", api_notification_channel_types),
|
||||||
|
web.get("/api/0/notification_channels", api_notification_channels_get),
|
||||||
|
web.post("/api/0/notification_channels", api_notification_channels_post),
|
||||||
|
web.put("/api/0/notification_channels/{name}", api_notification_channel_put),
|
||||||
|
web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete),
|
||||||
# Hosts
|
# Hosts
|
||||||
web.get("/api/0/hosts", api_hosts),
|
web.get("/api/0/hosts", api_hosts),
|
||||||
web.get("/api/0/alert_summary", api_alert_summary),
|
web.get("/api/0/alert_summary", api_alert_summary),
|
||||||
@@ -1269,6 +1706,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:
|
||||||
|
|||||||
@@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send
|
|||||||
|
|
||||||
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||||
"""Send *notif* to a single named channel, honouring min_level."""
|
"""Send *notif* to a single named channel, honouring min_level."""
|
||||||
|
# Strip ownership metadata — notifier drivers only need delivery credentials.
|
||||||
|
channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")}
|
||||||
|
|
||||||
level = notif.level.upper()
|
level = notif.level.upper()
|
||||||
if level != "RECOVER":
|
if level != "RECOVER":
|
||||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||||
|
|||||||
+93
-17
@@ -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,7 +286,10 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"owner": hcfg.get("owner", ""),
|
"owner": hcfg.get("owner", ""),
|
||||||
"managers": hcfg.get("managers", []),
|
"managers": hcfg.get("managers", []),
|
||||||
"monitors": hcfg.get("monitors", []),
|
"monitors": hcfg.get("monitors", []),
|
||||||
"threshold_config": hcfg.get("threshold_config", ""),
|
"threshold_configs": (
|
||||||
|
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
||||||
|
else ([v] if v else [])
|
||||||
|
),
|
||||||
"notification_channels": hcfg.get("notification_channels", []),
|
"notification_channels": hcfg.get("notification_channels", []),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -337,10 +398,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
{
|
{
|
||||||
"id": "dns",
|
"id": "dns",
|
||||||
"title": "Dynamic DNS",
|
"title": "Dynamic DNS",
|
||||||
"description": "nsupdate-based DNS registration — edit raw YAML.",
|
"description": "nsupdate-based DNS registration via nsupdate(8).",
|
||||||
"section_mode": "yaml",
|
"section_mode": "form",
|
||||||
"api_section": "dns",
|
"api_section": "dns",
|
||||||
"fields": [],
|
"fields": [
|
||||||
|
field("nsupdate_bin", "nsupdate binary", "path",
|
||||||
|
"Path to the nsupdate binary.", editable=True),
|
||||||
|
field("rndc_key", "RNDC key file", "path",
|
||||||
|
"Path to the rndc key file used to authenticate DNS updates.", editable=True),
|
||||||
|
field("dyndomains", "Dynamic domains", "list",
|
||||||
|
"Domains updated via nsupdate when a host with dyndns: true reports in.",
|
||||||
|
editable=True),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "users",
|
"id": "users",
|
||||||
@@ -368,7 +437,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "channels",
|
"id": "channels",
|
||||||
"title": "Notification Channels",
|
"title": "Notification Channels",
|
||||||
"description": "Named notification providers. Credentials are masked.",
|
"description": "Named notification providers. Credentials are masked.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "channels",
|
||||||
"api_section": "notification_channels",
|
"api_section": "notification_channels",
|
||||||
"channels": notif_channels,
|
"channels": notif_channels,
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -380,7 +449,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "hosts",
|
"id": "hosts",
|
||||||
"title": "Hosts",
|
"title": "Hosts",
|
||||||
"description": "Host definitions loaded from the config file.",
|
"description": "Host definitions loaded from the config file.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "hosts",
|
||||||
"api_section": "hosts",
|
"api_section": "hosts",
|
||||||
"hosts": hosts_list,
|
"hosts": hosts_list,
|
||||||
"fields": [],
|
"fields": [],
|
||||||
@@ -389,12 +458,12 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"id": "thresholds",
|
"id": "thresholds",
|
||||||
"title": "Threshold Configurations",
|
"title": "Threshold Configurations",
|
||||||
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||||
"section_mode": "yaml",
|
"section_mode": "thresholds",
|
||||||
"api_section": "thresholds",
|
"api_section": "thresholds",
|
||||||
"threshold_configs": threshold_config_list,
|
"threshold_configs": threshold_config_list,
|
||||||
"fields": [
|
"fields": [
|
||||||
field("default_threshold_config", "Default config", "text",
|
field("default_threshold_config", "Default config", "text",
|
||||||
"Threshold config used for hosts with no explicit mapping."),
|
"Threshold config used for hosts with no explicit mapping.", editable=True),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -419,4 +488,11 @@ def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
|||||||
"""Return sections list + auxiliary data for the settings template."""
|
"""Return sections list + auxiliary data for the settings template."""
|
||||||
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
||||||
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
||||||
return {"sections": sections, "all_channel_names": all_channel_names}
|
all_usernames = sorted((config.get("users") or {}).keys())
|
||||||
|
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
|
||||||
|
return {
|
||||||
|
"sections": sections,
|
||||||
|
"all_channel_names": all_channel_names,
|
||||||
|
"all_usernames": all_usernames,
|
||||||
|
"all_threshold_configs": all_threshold_configs,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
|
|
||||||
.alert-duration {
|
.alert-duration {
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-actions {
|
.alert-actions {
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
.refresh-info {
|
.refresh-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid #e0e0e0;
|
||||||
@@ -305,6 +305,31 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .summary-card { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .summary-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .filters { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .filter-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .filter-button { background: var(--surface-2); border-color: var(--border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .filter-button.active { background: #2196f3; color: #fff; border-color: #2196f3; }
|
||||||
|
html[data-theme="dark"] .filter-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .alerts-container { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .alert-item { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .alert-item.acknowledged { background: var(--surface-3); }
|
||||||
|
html[data-theme="dark"] .alert-item.critical { background: #2e0a0a; border-left-color: #f44336; }
|
||||||
|
html[data-theme="dark"] .alert-item.warning { background: #2e1a00; border-left-color: #ff9800; }
|
||||||
|
html[data-theme="dark"] .alert-item.unknown { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .alert-hostname { color: var(--link); }
|
||||||
|
html[data-theme="dark"] .alert-details { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .alert-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .alert-duration { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .last-update { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .refresh-info { color: var(--text-muted); border-top-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .no-alerts,
|
||||||
|
html[data-theme="dark"] .loading { color: var(--text-muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,10 +933,11 @@
|
|||||||
function renderOsInfoTable(d) {
|
function renderOsInfoTable(d) {
|
||||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||||
'processor','architecture','node','python_version',
|
'processor','architecture','node','python_version',
|
||||||
'python_implementation','hbc_version',
|
'python_implementation',
|
||||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||||
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||||
const shown = new Set(ORDER);
|
const shown = new Set(ORDER);
|
||||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||||
|
|
||||||
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
@@ -1206,9 +1346,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 +1371,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;
|
||||||
}
|
}
|
||||||
@@ -215,11 +215,109 @@
|
|||||||
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
.save-row { display: flex; align-items: center; margin-top: 8px; }
|
||||||
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
|
||||||
.btn-save:hover { background: #0055aa; }
|
.btn-save:hover { background: #0055aa; }
|
||||||
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
|
/* ---- Channel chip picker ---- */
|
||||||
.channel-item:last-child { border-bottom: none; }
|
.ch-picker { }
|
||||||
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
|
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
|
||||||
.channel-item .ch-name { font-weight: 500; color: #222; }
|
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
|
||||||
.channel-item .ch-meta { font-size: .8em; color: #888; }
|
.ch-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
|
||||||
|
border: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.ch-chip.selected:hover { background: #bbdefb; }
|
||||||
|
.ch-chip.available { background: #f1f3f4; color: #555; }
|
||||||
|
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
|
||||||
|
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
|
||||||
|
|
||||||
|
/* ---- My Channels card list ---- */
|
||||||
|
.my-ch-card {
|
||||||
|
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.my-ch-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
|
||||||
|
}
|
||||||
|
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
||||||
|
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
|
||||||
|
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
|
||||||
|
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
|
||||||
|
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
|
||||||
|
.btn-sm-edit:hover { background: #666; }
|
||||||
|
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
|
||||||
|
.btn-sm-del:hover { background: #fce4ec; }
|
||||||
|
|
||||||
|
/* ---- 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>
|
||||||
@@ -318,37 +416,130 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Notification channels -->
|
<!-- Notification channels — chip picker -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Notification Channels</h2>
|
<h2>Notification Channels</h2>
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
|
||||||
{% if all_channel_names %}
|
{% if all_channels %}
|
||||||
<div id="channel-checkboxes">
|
<div class="ch-picker">
|
||||||
{% for ch_name in all_channel_names %}
|
<div class="ch-picker-label">Selected</div>
|
||||||
<div class="channel-item">
|
<div id="selected-chips" class="ch-chips">
|
||||||
<label>
|
{% for ch in all_channels %}
|
||||||
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
|
{% if ch.name in (current_user.notification_channels or []) %}
|
||||||
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
|
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||||||
<div>
|
{{ ch.name | e }} <span class="ch-chip-x">×</span>
|
||||||
<div class="ch-name">{{ ch_name | e }}</div>
|
</button>
|
||||||
</div>
|
{% endif %}
|
||||||
</label>
|
{% endfor %}
|
||||||
|
{% set selected_set = current_user.notification_channels or [] %}
|
||||||
|
{% set has_selected = selected_set | length > 0 %}
|
||||||
|
{% if not has_selected %}
|
||||||
|
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ch-picker-label">Available</div>
|
||||||
|
<div id="available-chips" class="ch-chips">
|
||||||
|
{% for ch in all_channels %}
|
||||||
|
{% if ch.name not in (current_user.notification_channels or []) %}
|
||||||
|
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
|
||||||
|
+ {{ ch.name | e }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels configured.</p>
|
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="save-row" style="margin-top:10px">
|
<div class="save-row">
|
||||||
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
<button class="btn-save" onclick="saveChannels()">Save channels</button>
|
||||||
<span id="channels-status" class="status-msg"></span>
|
<span id="channels-status" class="status-msg"></span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="no-hosts">No personal notification channels configured.</span>
|
<span class="no-hosts">Log in to manage notification channels.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- My Channels — create/edit/delete own channels -->
|
||||||
|
{% if current_user %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>My Channels</h2>
|
||||||
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
|
||||||
|
<div id="my-channels-list">
|
||||||
|
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
||||||
|
{% for ch in my_channels %}
|
||||||
|
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
|
||||||
|
<div class="my-ch-header">
|
||||||
|
<span class="my-ch-name">{{ ch.name | e }}</span>
|
||||||
|
<span class="my-ch-type">{{ ch.type | e }}</span>
|
||||||
|
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
|
||||||
|
<span class="my-ch-actions">
|
||||||
|
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
||||||
|
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not my_channels %}
|
||||||
|
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="save-row" style="margin-top:8px">
|
||||||
|
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My Channels modal -->
|
||||||
|
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
|
||||||
|
<div class="ch-modal-box">
|
||||||
|
<h3 id="my-ch-modal-title">New Channel</h3>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Channel name</label>
|
||||||
|
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="my-ch-type" onchange="onMyChTypeChange()">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="my-ch-type-fields"></div>
|
||||||
|
<div class="ch-form-divider">Options</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Minimum alert level</label>
|
||||||
|
<select id="my-ch-min-level">
|
||||||
|
<option value="WARNING">WARNING (and above)</option>
|
||||||
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="my-ch-private"> Private — visible only to you
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="my-ch-modal-status" class="ch-modal-status"></div>
|
||||||
|
<div class="ch-modal-footer">
|
||||||
|
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
||||||
|
<button class="btn-save" onclick="saveMyChannel()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Appearance -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Theme</span>
|
||||||
|
<div class="theme-btns">
|
||||||
|
<button class="theme-btn" data-theme-val="auto" onclick="setTheme('auto')">Auto</button>
|
||||||
|
<button class="theme-btn" data-theme-val="light" onclick="setTheme('light')">Light</button>
|
||||||
|
<button class="theme-btn" data-theme-val="dark" onclick="setTheme('dark')">Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Host access -->
|
<!-- Host access -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Host Access</h2>
|
<h2>Host Access</h2>
|
||||||
@@ -395,6 +586,29 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// ---- Theme ----
|
||||||
|
function applyTheme(pref) {
|
||||||
|
var dark = pref === 'dark' ||
|
||||||
|
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||||||
|
else { document.documentElement.removeAttribute('data-theme'); }
|
||||||
|
}
|
||||||
|
function setTheme(pref) {
|
||||||
|
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
|
||||||
|
applyTheme(pref);
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||||
|
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(function() {
|
||||||
|
var pref = 'auto';
|
||||||
|
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||||
|
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---- Identity ----
|
||||||
async function saveIdentity() {
|
async function saveIdentity() {
|
||||||
const full_name = document.getElementById('profile-fullname').value;
|
const full_name = document.getElementById('profile-fullname').value;
|
||||||
const avatar = document.getElementById('profile-avatar').value;
|
const avatar = document.getElementById('profile-avatar').value;
|
||||||
@@ -411,6 +625,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Password ----
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
const current = document.getElementById('profile-current-pw').value;
|
const current = document.getElementById('profile-current-pw').value;
|
||||||
const newpw = document.getElementById('profile-new-pw').value;
|
const newpw = document.getElementById('profile-new-pw').value;
|
||||||
@@ -433,9 +648,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Channel chip picker ----
|
||||||
|
function toggleChip(btn) {
|
||||||
|
const name = btn.dataset.ch;
|
||||||
|
const isSelected = btn.classList.contains('selected');
|
||||||
|
if (isSelected) {
|
||||||
|
// Move to available
|
||||||
|
btn.classList.remove('selected');
|
||||||
|
btn.classList.add('available');
|
||||||
|
btn.innerHTML = '+ ' + escHtml(name);
|
||||||
|
btn.onclick = function() { toggleChip(this); };
|
||||||
|
document.getElementById('available-chips').appendChild(btn);
|
||||||
|
// Remove "None selected" placeholder if it exists
|
||||||
|
} else {
|
||||||
|
// Move to selected
|
||||||
|
btn.classList.remove('available');
|
||||||
|
btn.classList.add('selected');
|
||||||
|
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
|
||||||
|
btn.onclick = function() { toggleChip(this); };
|
||||||
|
document.getElementById('selected-chips').appendChild(btn);
|
||||||
|
}
|
||||||
|
// Update placeholder visibility
|
||||||
|
const sel = document.getElementById('selected-chips');
|
||||||
|
const placeholder = sel.querySelector('span[style]');
|
||||||
|
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
|
||||||
|
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
async function saveChannels() {
|
async function saveChannels() {
|
||||||
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
|
const notification_channels = [
|
||||||
.map(cb => cb.value);
|
...document.querySelectorAll('#selected-chips .ch-chip.selected')
|
||||||
|
].map(b => b.dataset.ch);
|
||||||
const resp = await fetch('/api/0/users/me', {
|
const resp = await fetch('/api/0/users/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -449,6 +692,138 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- My Channels CRUD ----
|
||||||
|
let _myChSchemas = {};
|
||||||
|
let _myChEditName = null;
|
||||||
|
|
||||||
|
async function _loadMyChSchemas() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channel_types');
|
||||||
|
_myChSchemas = await r.json();
|
||||||
|
const sel = document.getElementById('my-ch-type');
|
||||||
|
if (!sel) return;
|
||||||
|
Object.entries(_myChSchemas).forEach(([k, v]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = k; opt.textContent = v.label;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMyChTypeChange() {
|
||||||
|
const type = document.getElementById('my-ch-type').value;
|
||||||
|
const container = document.getElementById('my-ch-type-fields');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!type || !_myChSchemas[type]) return;
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'ch-form-divider';
|
||||||
|
divider.textContent = _myChSchemas[type].label + ' settings';
|
||||||
|
container.appendChild(divider);
|
||||||
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ch-form-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||||||
|
inp.id = 'mychf-' + sf.key;
|
||||||
|
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||||||
|
inp.autocomplete = 'off';
|
||||||
|
row.appendChild(lbl);
|
||||||
|
row.appendChild(inp);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMyChModal(name) {
|
||||||
|
_myChEditName = name || null;
|
||||||
|
document.getElementById('my-ch-modal-status').textContent = '';
|
||||||
|
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
|
||||||
|
document.getElementById('my-ch-name').value = name || '';
|
||||||
|
document.getElementById('my-ch-name').disabled = !!name;
|
||||||
|
document.getElementById('my-ch-type').value = '';
|
||||||
|
document.getElementById('my-ch-type-fields').innerHTML = '';
|
||||||
|
document.getElementById('my-ch-min-level').value = 'WARNING';
|
||||||
|
document.getElementById('my-ch-private').checked = false;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels');
|
||||||
|
const channels = await r.json();
|
||||||
|
const ch = channels.find(c => c.name === name);
|
||||||
|
if (ch) {
|
||||||
|
document.getElementById('my-ch-type').value = ch.type;
|
||||||
|
onMyChTypeChange();
|
||||||
|
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
|
document.getElementById('my-ch-private').checked = ch.private || false;
|
||||||
|
(ch.fields || []).forEach(f => {
|
||||||
|
const inp = document.getElementById('mychf-' + f.key);
|
||||||
|
if (inp) inp.value = f.value || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('Failed to load channel', e); }
|
||||||
|
}
|
||||||
|
document.getElementById('my-ch-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMyChModal() {
|
||||||
|
document.getElementById('my-ch-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMyChannel() {
|
||||||
|
const name = document.getElementById('my-ch-name').value.trim();
|
||||||
|
const type = document.getElementById('my-ch-type').value;
|
||||||
|
const minLevel = document.getElementById('my-ch-min-level').value;
|
||||||
|
const isPrivate = document.getElementById('my-ch-private').checked;
|
||||||
|
const statusEl = document.getElementById('my-ch-modal-status');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
|
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||||||
|
if (_myChSchemas[type]) {
|
||||||
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const inp = document.getElementById('mychf-' + sf.key);
|
||||||
|
if (inp) body[sf.key] = inp.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!_myChEditName;
|
||||||
|
const url = isEdit
|
||||||
|
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
|
||||||
|
: '/api/0/notification_channels';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
|
if (r.ok) {
|
||||||
|
closeMyChModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
statusEl.textContent = err.error || 'Error saving.';
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.textContent = 'Network error: ' + e.message;
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMyChannel(name) {
|
||||||
|
if (!confirm('Delete channel "' + name + '"?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
alert('Error: ' + (err.error || 'Could not delete.'));
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Network error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Utilities ----
|
||||||
function showStatus(id, msg, color) {
|
function showStatus(id, msg, color) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -456,6 +831,12 @@
|
|||||||
el.style.color = color;
|
el.style.color = color;
|
||||||
setTimeout(() => { el.textContent = ''; }, 3000);
|
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
html, body { overflow: visible; }
|
html, body { overflow: visible; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
color: #444;
|
color: #444;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
transition: background 0.1s, color 0.1s;
|
transition: background 0.1s, color 0.1s;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
.channel-field {
|
.channel-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
border-bottom: 1px solid #f5f5f5;
|
border-bottom: 1px solid #f5f5f5;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,36 @@
|
|||||||
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
||||||
.channel-field-value { color: #333; word-break: break-all; }
|
.channel-field-value { color: #333; word-break: break-all; }
|
||||||
|
|
||||||
|
/* ---- Channel management (form-based section) ---- */
|
||||||
|
.channel-header-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
.ch-owner-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.ch-private-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
|
||||||
|
.ch-level-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fff3e0; color: #e65100; }
|
||||||
|
.channel-grid { padding: 12px 20px 0; }
|
||||||
|
.channel-add-bar { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #f0f0f0; }
|
||||||
|
|
||||||
|
/* Channel modal */
|
||||||
|
.ch-modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1001;
|
||||||
|
}
|
||||||
|
.ch-modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px;
|
||||||
|
min-width: 360px; max-width: 520px; width: 95%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
|
||||||
|
.ch-form-row { margin-bottom: 12px; }
|
||||||
|
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
|
||||||
|
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
|
||||||
|
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
|
||||||
|
font-size: .88em; box-sizing: border-box; font-family: inherit;
|
||||||
|
}
|
||||||
|
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
|
||||||
|
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
|
||||||
|
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||||
|
.ch-status { font-size: .83em; margin-top: 8px; }
|
||||||
|
|
||||||
/* ---- Hosts table ---- */
|
/* ---- Hosts table ---- */
|
||||||
/* ---- Mobile: collapsible sidebar ---- */
|
/* ---- Mobile: collapsible sidebar ---- */
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
@@ -268,8 +298,6 @@
|
|||||||
|
|
||||||
/* ---- Editable inputs ---- */
|
/* ---- Editable inputs ---- */
|
||||||
.field-input {
|
.field-input {
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -322,7 +350,7 @@
|
|||||||
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
||||||
|
|
||||||
/* ---- Button styles ---- */
|
/* ---- Button styles ---- */
|
||||||
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; }
|
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 1.00em; cursor: pointer; }
|
||||||
.btn-primary { background: #0066cc; color: #fff; }
|
.btn-primary { background: #0066cc; color: #fff; }
|
||||||
.btn-primary:hover { background: #0055aa; }
|
.btn-primary:hover { background: #0055aa; }
|
||||||
.btn-success { background: #2a7a2a; color: #fff; }
|
.btn-success { background: #2a7a2a; color: #fff; }
|
||||||
@@ -337,7 +365,7 @@
|
|||||||
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
|
||||||
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
|
||||||
.crud-table tbody tr:last-child td { border-bottom: none; }
|
.crud-table tbody tr:last-child td { border-bottom: none; }
|
||||||
.crud-table .field-input { max-width: none; }
|
.crud-table .field-input { width: 100%; }
|
||||||
|
|
||||||
/* ---- Rollback modal ---- */
|
/* ---- Rollback modal ---- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
@@ -352,9 +380,149 @@
|
|||||||
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
|
||||||
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
|
||||||
.backup-row:last-child { border-bottom: none; }
|
.backup-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ---- Threshold config cards ---- */
|
||||||
|
.thresh-cfg-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thresh-cfg-header {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.thresh-cfg-name-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
.thresh-metric-table { width: 100%; }
|
||||||
|
.thresh-metric-table th { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- Multi-picker ---- */
|
||||||
|
.mpick-wrapper { display: block; }
|
||||||
|
.mpick-display {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
|
||||||
|
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
|
||||||
|
background: #fff; user-select: none; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
|
||||||
|
.mpick-tag {
|
||||||
|
padding: 1px 6px; background: #e8eaf6; color: #283593;
|
||||||
|
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
|
||||||
|
.mpick-panel {
|
||||||
|
position: fixed; background: #fff; border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
|
||||||
|
z-index: 2000; width: 360px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mpick-panel-header {
|
||||||
|
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
|
||||||
|
border-bottom: 1px solid #eee; display: flex;
|
||||||
|
justify-content: space-between; align-items: center; background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.mpick-panel-body { display: flex; }
|
||||||
|
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
|
||||||
|
.mpick-col-header {
|
||||||
|
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
|
||||||
|
border-bottom: 1px solid #f0f0f0; background: #fafafa;
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||||||
|
.mpick-item {
|
||||||
|
padding: 5px 10px; font-size: 1.00em; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||||||
|
}
|
||||||
|
.mpick-item:last-child { border-bottom: none; }
|
||||||
|
.mpick-item-avail:hover { background: #e8f5e9; }
|
||||||
|
.mpick-item-sel:hover { background: #fce4ec; }
|
||||||
|
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
|
||||||
|
.mpick-item:hover .mpick-arrow { opacity: 1; }
|
||||||
|
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
|
||||||
|
.mpick-item-sel .mpick-arrow { color: #c62828; }
|
||||||
|
.mpick-panel-footer {
|
||||||
|
padding: 6px 10px; border-top: 1px solid #eee;
|
||||||
|
display: flex; justify-content: flex-end; background: #f8f8f8;
|
||||||
|
}
|
||||||
|
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a:hover { background: var(--surface-3); color: var(--link); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a.active { background: #1a3255; color: #60a5fa; }
|
||||||
|
html[data-theme="dark"] .sidebar-toggle { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 4px var(--shadow); }
|
||||||
|
html[data-theme="dark"] .section-header { border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .section-title { color: var(--text-2); }
|
||||||
|
html[data-theme="dark"] .section-desc { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .section-footer { border-top-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .field-row { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .field-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .field-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .field-desc { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .val-boolean.on { background: #0d2e17; color: #66bb6a; }
|
||||||
|
html[data-theme="dark"] .val-boolean.off { background: #2e0d0d; color: #ef9a9a; }
|
||||||
|
html[data-theme="dark"] .val-tag { background: #1a2d5a; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .val-empty { color: var(--text-dim); }
|
||||||
|
html[data-theme="dark"] .val-masked { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .mini-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mini-table td { border-bottom-color: var(--border-3); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .mini-table tbody tr:hover { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .channel-card { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .channel-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .channel-name-text { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .channel-field { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .channel-field-label { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .channel-field-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-card { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-name-label { color: #60a5fa; }
|
||||||
|
html[data-theme="dark"] .crud-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .crud-table td { border-bottom-color: var(--border-3); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .yaml-editor { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .pending-banner { background: #2d2400; border-color: #a08020; }
|
||||||
|
html[data-theme="dark"] .pending-banner .pending-msg { color: #e8c840; }
|
||||||
|
html[data-theme="dark"] .modal-box,
|
||||||
|
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .modal-box h3,
|
||||||
|
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .backup-row { border-bottom-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .mpick-display { background: var(--input-bg); border-color: var(--input-border); }
|
||||||
|
html[data-theme="dark"] .mpick-display:hover { border-color: var(--link); background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .mpick-tag { background: #1a2d5a; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .mpick-more,
|
||||||
|
html[data-theme="dark"] .mpick-empty { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .mpick-panel { background: var(--surface); border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-panel-header { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-item { border-bottom-color: var(--border-4); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .mpick-item-avail:hover { background: #0d2e17; }
|
||||||
|
html[data-theme="dark"] .mpick-item-sel:hover { background: #2e0d0d; }
|
||||||
|
html[data-theme="dark"] .mpick-panel-footer { background: var(--surface-2); border-top-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-none { color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
{%- macro mpick(all_items, sel, cls) -%}
|
||||||
|
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
|
||||||
|
{%- endmacro %}
|
||||||
{% include 'nav.html' %}
|
{% include 'nav.html' %}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -381,6 +549,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel add/edit modal -->
|
||||||
|
<div id="ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeChannelModal()">
|
||||||
|
<div class="ch-modal-box">
|
||||||
|
<h3 id="ch-modal-title">Add Notification Channel</h3>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Channel name</label>
|
||||||
|
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="ch-type" onchange="onChTypeChange()">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="ch-type-fields"></div>
|
||||||
|
<div class="ch-form-divider">Options</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label>Minimum alert level</label>
|
||||||
|
<select id="ch-min-level">
|
||||||
|
<option value="WARNING">WARNING (and above)</option>
|
||||||
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ch-form-row">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="ch-private"> Private — visible only to you
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="ch-modal-status" class="ch-status"></div>
|
||||||
|
<div class="ch-modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveChannel()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
|
|
||||||
<!-- Sidebar navigation -->
|
<!-- Sidebar navigation -->
|
||||||
@@ -434,12 +638,8 @@
|
|||||||
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
|
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
|
||||||
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
||||||
<td style="min-width:120px">
|
<td style="min-width:140px">
|
||||||
{% for ch in all_channel_names %}
|
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
|
||||||
<label style="display:block;font-size:.82em;white-space:nowrap">
|
|
||||||
<input type="checkbox" class="user-ch" value="{{ ch | e }}" {% if ch in u.notification_channels %}checked{% endif %}> {{ ch | e }}
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
||||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
@@ -488,6 +688,171 @@
|
|||||||
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ---- Hosts CRUD table ---- #}
|
||||||
|
{% elif section.section_mode == 'hosts' %}
|
||||||
|
<div style="overflow-x:auto;padding:0 20px">
|
||||||
|
<table class="crud-table" id="hosts-editor">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>Watch</th>
|
||||||
|
<th>DynDNS</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th style="min-width:110px">Managers</th>
|
||||||
|
<th style="min-width:110px">Monitors</th>
|
||||||
|
<th style="min-width:110px">Threshold config</th>
|
||||||
|
<th style="min-width:110px">Channels</th>
|
||||||
|
<th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="hosts-tbody">
|
||||||
|
{% for h in section.hosts %}
|
||||||
|
<tr data-host-row="true" data-hostname="{{ h.name | e }}">
|
||||||
|
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
||||||
|
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
||||||
|
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
||||||
|
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
||||||
|
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
||||||
|
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
||||||
|
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="addHostRow()" style="margin-right:auto">+ Add host</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageHostsSection()">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Notification channels (form-based, live CRUD) ---- #}
|
||||||
|
{% elif section.section_mode == 'channels' %}
|
||||||
|
{% for f in section.fields %}
|
||||||
|
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||||||
|
<div class="field-label">{{ f.label }}</div>
|
||||||
|
<div class="field-body">
|
||||||
|
{% if f.type == 'list' %}
|
||||||
|
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||||||
|
{% else %}<span class="val-empty">None</span>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="channel-grid" id="channel-cards">
|
||||||
|
{% for ch in section.channels %}
|
||||||
|
<div class="channel-card" id="chcard-{{ ch.name | e }}">
|
||||||
|
<div class="channel-header">
|
||||||
|
<span class="channel-name-text">{{ ch.name | e }}</span>
|
||||||
|
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
|
||||||
|
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
|
||||||
|
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
|
||||||
|
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
|
||||||
|
<span class="channel-header-actions">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
|
||||||
|
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-fields">
|
||||||
|
{% for f in ch.fields %}
|
||||||
|
<div class="channel-field">
|
||||||
|
<span class="channel-field-label">{{ f.label }}</span>
|
||||||
|
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc">—</span>{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="channel-add-bar">
|
||||||
|
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Threshold configurations (form-based) ---- #}
|
||||||
|
{% elif section.section_mode == 'thresholds' %}
|
||||||
|
{% for f in section.fields %}
|
||||||
|
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||||||
|
<div class="field-label">{{ f.label }}</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<input type="text" class="field-input thresh-default-config"
|
||||||
|
value="{{ f.raw if f.raw is not none else '' }}"
|
||||||
|
placeholder="default"
|
||||||
|
list="thresh-cfg-names-{{ section.id }}">
|
||||||
|
<datalist id="thresh-cfg-names-{{ section.id }}">
|
||||||
|
{% for tc in section.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
|
||||||
|
{% for tc in section.threshold_configs %}
|
||||||
|
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
|
||||||
|
<div class="thresh-cfg-header">
|
||||||
|
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
|
||||||
|
{% if tc.name != 'default' %}
|
||||||
|
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="crud-table thresh-metric-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Metric path</th><th>Op</th>
|
||||||
|
<th>Warning</th><th>Critical</th>
|
||||||
|
<th>Hysteresis</th><th>Count</th>
|
||||||
|
<th title="Grace period (s) — overrides global; empty = use global">Grace</th>
|
||||||
|
<th style="max-width:160px">Display</th>
|
||||||
|
<th>En</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in tc.metrics %}
|
||||||
|
<tr data-metric-row="true" data-metric-path="{{ m.metric | e }}">
|
||||||
|
<td style="font-family:monospace;font-size:.85em;white-space:nowrap">{{ m.metric | e }}</td>
|
||||||
|
<td>
|
||||||
|
<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">
|
||||||
|
{% for op in ['>', '>=', '<', '<=', '==', '!=', 'nagios'] %}
|
||||||
|
<option value="{{ op }}" {% if m.operator == op %}selected{% endif %}>{{ op }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"
|
||||||
|
value="{{ m.warning if m.warning is not none else '' }}"
|
||||||
|
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||||||
|
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"
|
||||||
|
value="{{ m.critical if m.critical is not none else '' }}"
|
||||||
|
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||||||
|
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px"
|
||||||
|
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
|
||||||
|
value="{{ m.count if m.count is not none else 1 }}"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px"
|
||||||
|
value="{{ m.grace if m.grace is not none else '' }}"
|
||||||
|
placeholder="(global)"></td>
|
||||||
|
<td><input type="text" class="field-input thresh-display" style="width:150px"
|
||||||
|
value="{{ m.display | e }}" placeholder="(default)"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
|
||||||
|
{% if m.enabled %}checked{% endif %}></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||||||
|
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="section-footer" style="justify-content:space-between">
|
||||||
|
<button class="btn btn-secondary" onclick="addThresholdConfigCard('thresh-cfgs-{{ section.id }}')">+ Add config</button>
|
||||||
|
<button class="btn btn-primary" onclick="stageThresholdsSection('{{ section.id }}')">Stage changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ---- YAML editor section ---- #}
|
{# ---- YAML editor section ---- #}
|
||||||
{% elif section.section_mode == 'yaml' %}
|
{% elif section.section_mode == 'yaml' %}
|
||||||
<div style="padding: 12px 20px">
|
<div style="padding: 12px 20px">
|
||||||
@@ -516,6 +881,11 @@
|
|||||||
<input type="number" class="field-input"
|
<input type="number" class="field-input"
|
||||||
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
|
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
|
||||||
value="{{ f.raw if f.raw is not none else '' }}">
|
value="{{ f.raw if f.raw is not none else '' }}">
|
||||||
|
{% elif f.type == 'list' %}
|
||||||
|
<input type="text" class="field-input"
|
||||||
|
data-key="{{ f.key }}" data-type="list" data-section="{{ section.api_section }}"
|
||||||
|
value="{{ f.value | join(', ') if f.value else '' }}"
|
||||||
|
placeholder="comma-separated">
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="text" class="field-input"
|
<input type="text" class="field-input"
|
||||||
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||||||
@@ -554,8 +924,140 @@
|
|||||||
</div>{# /container #}
|
</div>{# /container #}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---- Channel names for add-user row ----
|
// ---- Lookup arrays for CRUD rows ----
|
||||||
const _allChannels = {{ all_channel_names | tojson }};
|
const _allChannels = {{ all_channel_names | tojson }};
|
||||||
|
const _allUsers = {{ all_usernames | tojson }};
|
||||||
|
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
|
||||||
|
|
||||||
|
// ---- Channel CRUD ----
|
||||||
|
let _channelSchemas = {};
|
||||||
|
let _chEditName = null; // null = create mode, string = edit mode
|
||||||
|
|
||||||
|
async function _loadChannelSchemas() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channel_types');
|
||||||
|
_channelSchemas = await r.json();
|
||||||
|
const sel = document.getElementById('ch-type');
|
||||||
|
if (!sel) return;
|
||||||
|
Object.entries(_channelSchemas).forEach(([k, v]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = k; opt.textContent = v.label;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChTypeChange() {
|
||||||
|
const type = document.getElementById('ch-type').value;
|
||||||
|
const container = document.getElementById('ch-type-fields');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!type || !_channelSchemas[type]) return;
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'ch-form-divider';
|
||||||
|
divider.textContent = _channelSchemas[type].label + ' settings';
|
||||||
|
container.appendChild(divider);
|
||||||
|
(_channelSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ch-form-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||||||
|
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
|
||||||
|
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||||||
|
inp.id = 'chf-' + sf.key;
|
||||||
|
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||||||
|
inp.autocomplete = 'off';
|
||||||
|
row.appendChild(lbl);
|
||||||
|
row.appendChild(inp);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChannelModal(name) {
|
||||||
|
_chEditName = name || null;
|
||||||
|
document.getElementById('ch-modal-status').textContent = '';
|
||||||
|
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
|
||||||
|
document.getElementById('ch-name').value = name || '';
|
||||||
|
document.getElementById('ch-name').disabled = !!name;
|
||||||
|
document.getElementById('ch-type').value = '';
|
||||||
|
document.getElementById('ch-type-fields').innerHTML = '';
|
||||||
|
document.getElementById('ch-min-level').value = 'WARNING';
|
||||||
|
document.getElementById('ch-private').checked = false;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
// Load existing channel data via API
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels');
|
||||||
|
const channels = await r.json();
|
||||||
|
const ch = channels.find(c => c.name === name);
|
||||||
|
if (ch) {
|
||||||
|
document.getElementById('ch-type').value = ch.type;
|
||||||
|
onChTypeChange();
|
||||||
|
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
|
document.getElementById('ch-private').checked = ch.private || false;
|
||||||
|
(ch.fields || []).forEach(f => {
|
||||||
|
const inp = document.getElementById('chf-' + f.key);
|
||||||
|
if (inp) inp.value = f.value || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('Failed to load channel data', e); }
|
||||||
|
}
|
||||||
|
document.getElementById('ch-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChannelModal() {
|
||||||
|
document.getElementById('ch-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChannel() {
|
||||||
|
const name = document.getElementById('ch-name').value.trim();
|
||||||
|
const type = document.getElementById('ch-type').value;
|
||||||
|
const minLevel = document.getElementById('ch-min-level').value;
|
||||||
|
const isPrivate = document.getElementById('ch-private').checked;
|
||||||
|
const statusEl = document.getElementById('ch-modal-status');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
|
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||||||
|
if (_channelSchemas[type]) {
|
||||||
|
(_channelSchemas[type].fields || []).forEach(sf => {
|
||||||
|
const inp = document.getElementById('chf-' + sf.key);
|
||||||
|
if (inp) body[sf.key] = inp.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!_chEditName;
|
||||||
|
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
|
if (r.ok) {
|
||||||
|
closeChannelModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
statusEl.textContent = err.error || 'Error saving channel.';
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.textContent = 'Network error: ' + e.message;
|
||||||
|
statusEl.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChannel(name) {
|
||||||
|
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
alert('Error: ' + (err.error || 'Could not delete channel.'));
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Network error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Staged changes accumulator ----
|
// ---- Staged changes accumulator ----
|
||||||
const _staged = {};
|
const _staged = {};
|
||||||
@@ -566,9 +1068,13 @@
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
document.getElementById('pending-count').textContent = count;
|
document.getElementById('pending-count').textContent = count;
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
|
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
|
||||||
} else {
|
} else {
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
}
|
}
|
||||||
|
const navBtn = document.getElementById('nav-publish-btn');
|
||||||
|
if (navBtn) navBtn.style.display = count > 0 ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageFormSection(sectionId, apiSection) {
|
function stageFormSection(sectionId, apiSection) {
|
||||||
@@ -583,6 +1089,8 @@
|
|||||||
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
|
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
|
||||||
const v = parseInt(el.value, 10);
|
const v = parseInt(el.value, 10);
|
||||||
_staged[apiSection][key] = isNaN(v) ? null : v;
|
_staged[apiSection][key] = isNaN(v) ? null : v;
|
||||||
|
} else if (el.dataset.type === 'list') {
|
||||||
|
_staged[apiSection][key] = el.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
_staged[apiSection][key] = el.value;
|
_staged[apiSection][key] = el.value;
|
||||||
}
|
}
|
||||||
@@ -605,7 +1113,7 @@
|
|||||||
full_name: row.querySelector('.user-full-name').value,
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
avatar: row.querySelector('.user-avatar').value,
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
admin: row.querySelector('.user-admin').checked,
|
admin: row.querySelector('.user-admin').checked,
|
||||||
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
|
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
|
||||||
};
|
};
|
||||||
const pw = row.querySelector('.user-password').value;
|
const pw = row.querySelector('.user-password').value;
|
||||||
if (pw) entry.password = pw;
|
if (pw) entry.password = pw;
|
||||||
@@ -619,7 +1127,7 @@
|
|||||||
full_name: row.querySelector('.user-full-name').value,
|
full_name: row.querySelector('.user-full-name').value,
|
||||||
avatar: row.querySelector('.user-avatar').value,
|
avatar: row.querySelector('.user-avatar').value,
|
||||||
admin: row.querySelector('.user-admin').checked,
|
admin: row.querySelector('.user-admin').checked,
|
||||||
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
|
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
|
||||||
};
|
};
|
||||||
const pw = row.querySelector('.user-password').value;
|
const pw = row.querySelector('.user-password').value;
|
||||||
if (pw) entry.password = pw;
|
if (pw) entry.password = pw;
|
||||||
@@ -663,6 +1171,57 @@
|
|||||||
flashStaged('oauth');
|
flashStaged('oauth');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageHostsSection() {
|
||||||
|
function rowToEntry(row) {
|
||||||
|
const entry = {
|
||||||
|
watch: row.querySelector('.host-watch').checked,
|
||||||
|
dyndns: row.querySelector('.host-dyndns').checked,
|
||||||
|
};
|
||||||
|
const owner = row.querySelector('.host-owner').value.trim();
|
||||||
|
if (owner) entry.owner = owner;
|
||||||
|
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
|
||||||
|
if (managers.length) entry.managers = managers;
|
||||||
|
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
|
||||||
|
if (monitors.length) entry.monitors = monitors;
|
||||||
|
const tcs = [...(row.querySelector('.host-tc')?.selectedOptions || [])].map(o => o.value);
|
||||||
|
if (tcs.length) entry.threshold_config = tcs;
|
||||||
|
const chs = [...(row.querySelector('.host-channels')?.selectedOptions || [])].map(o => o.value);
|
||||||
|
if (chs.length) entry.notification_channels = chs;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
const hosts = {};
|
||||||
|
document.querySelectorAll('[data-host-row]').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
hosts[row.dataset.hostname] = rowToEntry(row);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-new-host]').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
const h = (row.querySelector('.new-hostname') || {value: ''}).value.trim();
|
||||||
|
if (!h) return;
|
||||||
|
hosts[h] = rowToEntry(row);
|
||||||
|
});
|
||||||
|
_staged['hosts'] = hosts;
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged('hosts');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHostRow() {
|
||||||
|
const tbody = document.getElementById('hosts-tbody');
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.setAttribute('data-new-host', 'true');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input class="field-input new-hostname" placeholder="hostname" required style="min-width:120px"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
|
||||||
|
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
|
||||||
|
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
|
||||||
|
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
|
||||||
|
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
|
||||||
|
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
async function publishAll() {
|
async function publishAll() {
|
||||||
const btn = document.querySelector('[onclick="publishAll()"]');
|
const btn = document.querySelector('[onclick="publishAll()"]');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -690,6 +1249,7 @@
|
|||||||
|
|
||||||
function discardAll() {
|
function discardAll() {
|
||||||
Object.keys(_staged).forEach(k => delete _staged[k]);
|
Object.keys(_staged).forEach(k => delete _staged[k]);
|
||||||
|
localStorage.removeItem('hbd_pending_config');
|
||||||
updatePendingBanner();
|
updatePendingBanner();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -707,6 +1267,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
_loadChannelSchemas();
|
||||||
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
||||||
const sectionId = ta.id.replace('yaml-', '');
|
const sectionId = ta.id.replace('yaml-', '');
|
||||||
const section = document.getElementById(sectionId);
|
const section = document.getElementById(sectionId);
|
||||||
@@ -731,9 +1292,6 @@
|
|||||||
|
|
||||||
function addUserRow() {
|
function addUserRow() {
|
||||||
const tbody = document.getElementById('users-tbody');
|
const tbody = document.getElementById('users-tbody');
|
||||||
const chHtml = _allChannels.map(ch =>
|
|
||||||
`<label style="display:block;font-size:.82em;white-space:nowrap"><input type="checkbox" class="user-ch" value="${escHtml(ch)}"> ${escHtml(ch)}</label>`
|
|
||||||
).join('');
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.setAttribute('data-new-user', 'true');
|
row.setAttribute('data-new-user', 'true');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -741,7 +1299,7 @@
|
|||||||
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
||||||
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
||||||
<td>${chHtml}</td>
|
<td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
|
||||||
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
||||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
@@ -821,6 +1379,121 @@
|
|||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Multi-picker ----
|
||||||
|
let _mpickPanel = null;
|
||||||
|
let _mpickTarget = null;
|
||||||
|
|
||||||
|
function _initMpickPanel() {
|
||||||
|
if (_mpickPanel) return;
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'mpick-panel';
|
||||||
|
p.style.display = 'none';
|
||||||
|
p.innerHTML = `
|
||||||
|
<div class="mpick-panel-header">
|
||||||
|
<span>Select items</span>
|
||||||
|
<button style="background:none;border:none;cursor:pointer;color:#888;font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-body">
|
||||||
|
<div class="mpick-col" id="mpick-avail-col">
|
||||||
|
<div class="mpick-col-header">Available</div>
|
||||||
|
<div id="mpick-avail"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-col" id="mpick-sel-col">
|
||||||
|
<div class="mpick-col-header">Selected</div>
|
||||||
|
<div id="mpick-sel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mpick-panel-footer">
|
||||||
|
<button class="btn btn-primary" style="font-size:.82em;padding:4px 12px" onclick="closeMpick()">Done</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(p);
|
||||||
|
_mpickPanel = p;
|
||||||
|
document.addEventListener('mousedown', e => {
|
||||||
|
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
|
||||||
|
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMpick(displayEl) {
|
||||||
|
if (displayEl.closest('tr')?.dataset.deleted === 'true') return;
|
||||||
|
_initMpickPanel();
|
||||||
|
_mpickTarget = displayEl.closest('.mpick-wrapper');
|
||||||
|
_rerenderMpick();
|
||||||
|
_mpickPanel.style.display = 'block';
|
||||||
|
const rect = displayEl.getBoundingClientRect();
|
||||||
|
const pw = _mpickPanel.offsetWidth || 360;
|
||||||
|
const ph = _mpickPanel.offsetHeight || 280;
|
||||||
|
let top = rect.bottom + 4;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
|
||||||
|
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
|
||||||
|
_mpickPanel.style.top = top + 'px';
|
||||||
|
_mpickPanel.style.left = left + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rerenderMpick() {
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const allOpts = [...sel.options];
|
||||||
|
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
|
||||||
|
const avail = allOpts.filter(o => !selVals.has(o.value));
|
||||||
|
const chosen = allOpts.filter(o => selVals.has(o.value));
|
||||||
|
document.getElementById('mpick-avail').innerHTML = avail.length
|
||||||
|
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">All selected</div>';
|
||||||
|
document.getElementById('mpick-sel').innerHTML = chosen.length
|
||||||
|
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">−</span></div>`).join('')
|
||||||
|
: '<div class="mpick-none">None selected</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mpickToggle(itemEl, toSelected) {
|
||||||
|
const val = itemEl.dataset.val;
|
||||||
|
const sel = _mpickTarget.querySelector('select');
|
||||||
|
const opt = [...sel.options].find(o => o.value === val);
|
||||||
|
if (opt) opt.selected = toSelected;
|
||||||
|
_updateMpickDisplay(_mpickTarget);
|
||||||
|
_rerenderMpick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMpickDisplay(wrapper) {
|
||||||
|
const sel = wrapper.querySelector('select');
|
||||||
|
const display = wrapper.querySelector('.mpick-display');
|
||||||
|
const selected = [...sel.selectedOptions].map(o => o.value);
|
||||||
|
if (!selected.length) {
|
||||||
|
display.innerHTML = '<span class="mpick-empty">(none)</span>';
|
||||||
|
display.title = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const MAX = 2;
|
||||||
|
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
display.innerHTML = html;
|
||||||
|
display.title = selected.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMpick() {
|
||||||
|
if (_mpickPanel) _mpickPanel.style.display = 'none';
|
||||||
|
_mpickTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMpickHTML(allItems, selectedItems, cls) {
|
||||||
|
const selSet = new Set(selectedItems);
|
||||||
|
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
|
||||||
|
const selected = allItems.filter(v => selSet.has(v));
|
||||||
|
const MAX = 2;
|
||||||
|
let dispHtml;
|
||||||
|
if (!selected.length) {
|
||||||
|
dispHtml = '<span class="mpick-empty">(none)</span>';
|
||||||
|
} else {
|
||||||
|
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||||||
|
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||||||
|
}
|
||||||
|
const title = escHtml(selected.join(', '));
|
||||||
|
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight sidebar link for the section currently in view
|
// Highlight sidebar link for the section currently in view
|
||||||
const sections = document.querySelectorAll('.section');
|
const sections = document.querySelectorAll('.section');
|
||||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||||
@@ -849,6 +1522,125 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Threshold configurations form ----
|
||||||
|
function stageThresholdsSection(sectionId) {
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
const configs = {};
|
||||||
|
|
||||||
|
function readMetrics(card) {
|
||||||
|
const metrics = {};
|
||||||
|
card.querySelectorAll('tbody tr').forEach(row => {
|
||||||
|
if (row.dataset.deleted === 'true') return;
|
||||||
|
const metric = row.dataset.metricPath
|
||||||
|
|| (row.querySelector('.new-metric-path')?.value || '').trim();
|
||||||
|
if (!metric) return;
|
||||||
|
const op = row.querySelector('.thresh-op')?.value || '>';
|
||||||
|
const warn = row.querySelector('.thresh-warn')?.value;
|
||||||
|
const crit = row.querySelector('.thresh-crit')?.value;
|
||||||
|
const hyst = row.querySelector('.thresh-hyst')?.value;
|
||||||
|
const count = row.querySelector('.thresh-count')?.value;
|
||||||
|
const grace = row.querySelector('.thresh-grace')?.value;
|
||||||
|
const display = row.querySelector('.thresh-display')?.value || '';
|
||||||
|
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
|
||||||
|
const entry = { operator: op, enabled: enabled };
|
||||||
|
if (warn !== '' && warn !== undefined) entry.warning = parseFloat(warn);
|
||||||
|
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
|
||||||
|
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
|
||||||
|
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
|
||||||
|
if (grace !== '' && grace !== undefined) entry.grace = parseFloat(grace);
|
||||||
|
if (display) entry.display = display;
|
||||||
|
metrics[metric] = entry;
|
||||||
|
});
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
|
||||||
|
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
|
||||||
|
const configName = card.dataset.configName
|
||||||
|
|| (card.querySelector('.new-config-name')?.value || '').trim();
|
||||||
|
if (!configName) return;
|
||||||
|
configs[configName] = readMetrics(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
_staged['thresholds'] = configs;
|
||||||
|
|
||||||
|
const defInput = section.querySelector('.thresh-default-config');
|
||||||
|
if (defInput) {
|
||||||
|
if (!_staged['server']) _staged['server'] = {};
|
||||||
|
_staged['server']['default_threshold_config'] = defInput.value || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePendingBanner();
|
||||||
|
flashStaged(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThreshOpChange(select) {
|
||||||
|
const row = select.closest('tr');
|
||||||
|
const isNagios = select.value === 'nagios';
|
||||||
|
const w = row.querySelector('.thresh-warn');
|
||||||
|
const c = row.querySelector('.thresh-crit');
|
||||||
|
if (w) w.disabled = isNagios;
|
||||||
|
if (c) c.disabled = isNagios;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _threshOpSelect(selected) {
|
||||||
|
const ops = ['>', '>=', '<', '<=', '==', '!=', 'nagios'];
|
||||||
|
return '<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">' +
|
||||||
|
ops.map(op => `<option value="${escHtml(op)}"${op === selected ? ' selected' : ''}>${escHtml(op)}</option>`).join('') +
|
||||||
|
'</select>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdMetricRow(tbody) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="text" class="field-input new-metric-path" placeholder="plugin.metric" style="min-width:160px;font-family:monospace;font-size:.85em" required></td>
|
||||||
|
<td>${_threshOpSelect('>')}</td>
|
||||||
|
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px" placeholder="(global)"></td>
|
||||||
|
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
|
||||||
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThresholdConfigCard(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'thresh-cfg-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="thresh-cfg-header">
|
||||||
|
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
|
||||||
|
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="crud-table thresh-metric-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Metric path</th><th>Op</th>
|
||||||
|
<th>Warning</th><th>Critical</th>
|
||||||
|
<th>Hysteresis</th><th>Count</th>
|
||||||
|
<th style="max-width:160px">Display</th>
|
||||||
|
<th>En</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||||||
|
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||||||
|
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteThresholdConfigCard(btn) {
|
||||||
|
const card = btn.closest('.thresh-cfg-card');
|
||||||
|
const name = card.dataset.configName || 'this config';
|
||||||
|
if (!confirm(`Delete config "${name}"?`)) return;
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
|
||||||
function closeSidebar() {
|
function closeSidebar() {
|
||||||
var sidebarNav = document.getElementById('sidebar-nav');
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
|||||||
+67
-15
@@ -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,
|
||||||
|
|||||||
+3
-1
@@ -333,6 +333,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"])
|
||||||
@@ -377,7 +379,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)
|
||||||
|
|
||||||
|
|||||||
+20
-8
@@ -4,20 +4,32 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.0"
|
version = "5.3.9"
|
||||||
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)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
key "rndc-key" {
|
|
||||||
algorithm hmac-md5;
|
|
||||||
secret "qlGa+AYKtyOgWNuozqECMw==";
|
|
||||||
};
|
|
||||||
+15
-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"
|
||||||
|
|||||||
+40
-26
@@ -1264,6 +1264,8 @@ static void usage(const char *prog) {
|
|||||||
" -c FILE Config file (JSON)\n"
|
" -c FILE Config file (JSON)\n"
|
||||||
" -m MSG Send one-shot message\n"
|
" -m MSG Send one-shot message\n"
|
||||||
" -n NAME Override hostname\n"
|
" -n NAME Override hostname\n"
|
||||||
|
" -4 Use IPv4 only\n"
|
||||||
|
" -6 Use IPv6 only\n"
|
||||||
" -d Daemonize\n"
|
" -d Daemonize\n"
|
||||||
" -v Verbose (info)\n"
|
" -v Verbose (info)\n"
|
||||||
" -x Debug\n"
|
" -x Debug\n"
|
||||||
@@ -1276,9 +1278,10 @@ int main(int argc, char **argv) {
|
|||||||
const char *cfgpath = NULL;
|
const char *cfgpath = NULL;
|
||||||
const char *message = NULL;
|
const char *message = NULL;
|
||||||
const char *nameov = NULL;
|
const char *nameov = NULL;
|
||||||
|
int af_filter = 0;
|
||||||
|
|
||||||
int opt;
|
int opt;
|
||||||
while ((opt = getopt(argc, argv, "bc:m:n:dvxh")) != -1) {
|
while ((opt = getopt(argc, argv, "bc:m:n:dvxh46")) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 'b': do_boot = true; break;
|
case 'b': do_boot = true; break;
|
||||||
case 'c': cfgpath = optarg; break;
|
case 'c': cfgpath = optarg; break;
|
||||||
@@ -1287,6 +1290,8 @@ int main(int argc, char **argv) {
|
|||||||
case 'd': do_daemon = true; break;
|
case 'd': do_daemon = true; break;
|
||||||
case 'v': g_log_level = LL_INFO; break;
|
case 'v': g_log_level = LL_INFO; break;
|
||||||
case 'x': g_log_level = LL_DEBUG; break;
|
case 'x': g_log_level = LL_DEBUG; break;
|
||||||
|
case '4': af_filter = AF_INET; break;
|
||||||
|
case '6': af_filter = AF_INET6; break;
|
||||||
case 'h': usage(argv[0]); return 0;
|
case 'h': usage(argv[0]); return 0;
|
||||||
default: usage(argv[0]); return 1;
|
default: usage(argv[0]); return 1;
|
||||||
}
|
}
|
||||||
@@ -1313,37 +1318,46 @@ int main(int argc, char **argv) {
|
|||||||
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
char *dot = strchr(iam, '.'); if (dot) *dot = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
int conn_id = 1;
|
|
||||||
for (int i = 0; i < nhost; i++) {
|
|
||||||
struct addrinfo hints = {0}, *res = NULL;
|
|
||||||
hints.ai_socktype = SOCK_DGRAM;
|
|
||||||
hints.ai_protocol = IPPROTO_UDP;
|
|
||||||
char ps[16]; snprintf(ps, sizeof(ps), "%d", cfg.hb_port);
|
|
||||||
if (getaddrinfo(hosts[i], ps, &hints, &res) != 0) {
|
|
||||||
LOGE("cannot resolve %s", hosts[i]); continue;
|
|
||||||
}
|
|
||||||
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
|
|
||||||
conn_t *c = &g_conns[g_nconns];
|
|
||||||
memset(c, 0, sizeof(*c));
|
|
||||||
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
|
||||||
c->af = ai->ai_family; c->sockfd = -1;
|
|
||||||
snprintf(c->name, sizeof(c->name), "%s", iam);
|
|
||||||
void *addr = (ai->ai_family == AF_INET)
|
|
||||||
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
|
||||||
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
|
||||||
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
|
||||||
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
|
||||||
}
|
|
||||||
freeaddrinfo(res);
|
|
||||||
}
|
|
||||||
if (!g_nconns) { LOGE("no connections established"); return 1; }
|
|
||||||
|
|
||||||
struct sigaction sa = {0};
|
struct sigaction sa = {0};
|
||||||
sa.sa_handler = sig_handler;
|
sa.sa_handler = sig_handler;
|
||||||
sigaction(SIGTERM, &sa, NULL);
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
sigaction(SIGINT, &sa, NULL);
|
sigaction(SIGINT, &sa, NULL);
|
||||||
sigaction(SIGHUP, &sa, NULL);
|
sigaction(SIGHUP, &sa, NULL);
|
||||||
|
|
||||||
|
int conn_id = 1;
|
||||||
|
int retry_delay = 5;
|
||||||
|
while (g_running && !g_nconns) {
|
||||||
|
for (int i = 0; i < nhost; i++) {
|
||||||
|
struct addrinfo hints = {0}, *res = NULL;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
hints.ai_protocol = IPPROTO_UDP;
|
||||||
|
hints.ai_family = af_filter;
|
||||||
|
char ps[16]; snprintf(ps, sizeof(ps), "%d", cfg.hb_port);
|
||||||
|
if (getaddrinfo(hosts[i], ps, &hints, &res) != 0) {
|
||||||
|
LOGW("cannot resolve %s — retrying in %ds", hosts[i], retry_delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (struct addrinfo *ai = res; ai && g_nconns < MAX_HOSTS; ai = ai->ai_next) {
|
||||||
|
conn_t *c = &g_conns[g_nconns];
|
||||||
|
memset(c, 0, sizeof(*c));
|
||||||
|
c->conn_id = conn_id++; c->port = cfg.hb_port;
|
||||||
|
c->af = ai->ai_family; c->sockfd = -1;
|
||||||
|
snprintf(c->name, sizeof(c->name), "%s", iam);
|
||||||
|
void *addr = (ai->ai_family == AF_INET)
|
||||||
|
? (void *)&((struct sockaddr_in *)ai->ai_addr)->sin_addr
|
||||||
|
: (void *)&((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr;
|
||||||
|
inet_ntop(ai->ai_family, addr, c->addr, sizeof(c->addr));
|
||||||
|
if (conn_open(c)) { g_nconns++; LOGI("connected to %s", c->addr); }
|
||||||
|
}
|
||||||
|
freeaddrinfo(res);
|
||||||
|
}
|
||||||
|
if (!g_nconns) {
|
||||||
|
sleep(retry_delay);
|
||||||
|
if (retry_delay < 60) retry_delay *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!g_nconns) return 1;
|
||||||
|
|
||||||
conn_t *primary = &g_conns[0];
|
conn_t *primary = &g_conns[0];
|
||||||
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
LOGI("hbc_mini-c %s on %s -> %s port=%d interval=%ds",
|
||||||
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
HBC_VERSION, iam, hosts[0], cfg.hb_port, cfg.interval);
|
||||||
|
|||||||
+24
-13
@@ -41,7 +41,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.9"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
@@ -1059,22 +1059,30 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
|
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
|
||||||
|
|
||||||
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
|
else 0)
|
||||||
|
|
||||||
connections: List[AsyncConnection] = []
|
connections: List[AsyncConnection] = []
|
||||||
conn_id = 1
|
conn_id = 1
|
||||||
for host in args.hosts:
|
_retry_delay = 5
|
||||||
try:
|
while _running and not connections:
|
||||||
addrs = socket.getaddrinfo(host, port, 0, 0, socket.SOL_UDP)
|
for host in args.hosts:
|
||||||
except socket.gaierror as e:
|
try:
|
||||||
log.error("cannot resolve %s: %s", host, e)
|
addrs = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
|
||||||
continue
|
except socket.gaierror as e:
|
||||||
for ai in addrs:
|
log.warning("cannot resolve %s: %s — retrying in %ds", host, e, _retry_delay)
|
||||||
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
continue
|
||||||
if await conn.open():
|
for ai in addrs:
|
||||||
connections.append(conn)
|
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
|
||||||
conn_id += 1
|
if await conn.open():
|
||||||
|
connections.append(conn)
|
||||||
|
conn_id += 1
|
||||||
|
if not connections:
|
||||||
|
await _sleep(_retry_delay)
|
||||||
|
_retry_delay = min(_retry_delay * 2, 60)
|
||||||
|
|
||||||
if not connections:
|
if not connections:
|
||||||
log.error("no connections established")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Boot / one-shot message
|
# Boot / one-shot message
|
||||||
@@ -1153,6 +1161,9 @@ def main(argv=None):
|
|||||||
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
|
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||||
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
|
parser.add_argument("-x", "--debug", action="count", default=0, help="Debug level")
|
||||||
|
af_group = parser.add_mutually_exclusive_group()
|
||||||
|
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
|
||||||
|
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
|
||||||
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
|
parser.add_argument("hosts", nargs="+", help="HBD server(s)")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
|
|||||||
import hbdclass
|
import hbdclass
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"config": {"watchhosts": [], "dyndnshosts": []},
|
"config": {"watchhosts": []},
|
||||||
"hbdclass": hbdclass,
|
"hbdclass": hbdclass,
|
||||||
"log": dummy_noop,
|
"log": dummy_noop,
|
||||||
"email": dummy_noop,
|
"email": dummy_noop,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests for _build_host_info helper in http.py."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from hbd.server.http import _build_host_info
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
def __init__(self, lastbeat):
|
||||||
|
self.lastbeat = lastbeat
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHost:
|
||||||
|
def __init__(self, name="myhost", owner=None, managers=None,
|
||||||
|
connections=None, os_data=None, plugin_data=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.managers = managers or []
|
||||||
|
self.connections = connections or {}
|
||||||
|
self._os_data = os_data
|
||||||
|
self.plugin_data = plugin_data or {}
|
||||||
|
|
||||||
|
def get_latest_plugin_data(self, plugin_name):
|
||||||
|
if plugin_name == "os_info" and self._os_data is not None:
|
||||||
|
return (1234567890.0, self._os_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_basic_fields():
|
||||||
|
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] == "alice"
|
||||||
|
assert result["managers"] == ["bob", "carol"]
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_no_owner():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["owner"] is None
|
||||||
|
assert result["managers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_reads_hbc_from_os_info():
|
||||||
|
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] == "5.3.0"
|
||||||
|
assert result["hbc_type"] == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_hbc_none_when_no_os_info():
|
||||||
|
host = _FakeHost(os_data=None)
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["hbc_version"] is None
|
||||||
|
assert result["hbc_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||||
|
host = _FakeHost(connections={
|
||||||
|
"IPv4": _FakeConn(1000.0),
|
||||||
|
"IPv6": _FakeConn(2000.0),
|
||||||
|
})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_last_packet_none_when_no_connections():
|
||||||
|
host = _FakeHost(connections={})
|
||||||
|
result = _build_host_info(host)
|
||||||
|
assert result["last_packet"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_none_without_checker():
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=None)
|
||||||
|
assert result["thresholds"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_sorted_by_metric():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||||
|
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||||
|
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {
|
||||||
|
"memory_monitor.memory_percent": tc_mem,
|
||||||
|
"cpu_monitor.cpu_percent": tc_cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
assert len(result["thresholds"]) == 2
|
||||||
|
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||||
|
assert result["thresholds"][0]["warning"] == 80.0
|
||||||
|
assert result["thresholds"][0]["critical"] == 95.0
|
||||||
|
assert result["thresholds"][0]["operator"] == ">"
|
||||||
|
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_threshold_null_warning_critical():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["warning"] is None
|
||||||
|
assert result["thresholds"][0]["critical"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_nagios_operator_serialized():
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
|
||||||
|
host = _FakeHost()
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"][0]["operator"] == "nagios"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_suffix_matched_metrics():
|
||||||
|
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost(
|
||||||
|
connections={},
|
||||||
|
os_data=None,
|
||||||
|
)
|
||||||
|
# Simulate plugin_data with both percent and swap_percent fields
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {
|
||||||
|
"percent": 80.0,
|
||||||
|
"swap_percent": 25.0,
|
||||||
|
"available_mb": 2000,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
assert result["thresholds"] is not None
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["metric"] == "memory_monitor.percent"
|
||||||
|
assert t["covers"] == ["memory_monitor.swap_percent"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_host_info_covers_empty_when_exact_matches_only():
|
||||||
|
"""No covers when all plugin fields match their threshold exactly."""
|
||||||
|
from hbd.server.threshold import ThresholdConfig
|
||||||
|
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||||
|
checker = MagicMock()
|
||||||
|
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||||
|
|
||||||
|
host = _FakeHost()
|
||||||
|
host.plugin_data = {
|
||||||
|
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_host_info(host, threshold_checker=checker)
|
||||||
|
t = result["thresholds"][0]
|
||||||
|
assert t["covers"] == []
|
||||||
@@ -83,3 +83,41 @@ def test_put_users_me_notification_channels(tmp_path):
|
|||||||
configio.write_config(str(cfg), data)
|
configio.write_config(str(cfg), data)
|
||||||
result = configio.read_roundtrip(str(cfg))
|
result = configio.read_roundtrip(str(cfg))
|
||||||
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
|
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_channels_excludes_private_from_others():
|
||||||
|
"""Private channels owned by another user must not appear in the visible set."""
|
||||||
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"notification_channels": {
|
||||||
|
"public_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
||||||
|
"recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"},
|
||||||
|
"bob_priv": {"type": "email", "owner": "bob", "private": True,
|
||||||
|
"recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeUser:
|
||||||
|
def __init__(self, username, admin=False):
|
||||||
|
self.username = username
|
||||||
|
self.admin = admin
|
||||||
|
|
||||||
|
alice = FakeUser("alice")
|
||||||
|
bob = FakeUser("bob")
|
||||||
|
admin = FakeUser("admin", admin=True)
|
||||||
|
|
||||||
|
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
|
||||||
|
def visible(user):
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user.admin:
|
||||||
|
return set(all_channels.keys())
|
||||||
|
return {
|
||||||
|
name for name, cfg in all_channels.items()
|
||||||
|
if not cfg.get("private") or cfg.get("owner") == user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
assert visible(alice) == {"public_ch", "alice_priv"}
|
||||||
|
assert visible(bob) == {"public_ch", "bob_priv"}
|
||||||
|
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import configio, settings as settings_mod
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_YAML = """\
|
||||||
|
hbd_port: 50004
|
||||||
|
notification_channels:
|
||||||
|
pushover_ops:
|
||||||
|
type: pushover
|
||||||
|
token: abc123
|
||||||
|
user: usr456
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# configio helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_apply_channel_adds_new_entry(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]})
|
||||||
|
assert "email_ops" in data["notification_channels"]
|
||||||
|
assert data["notification_channels"]["email_ops"]["type"] == "email"
|
||||||
|
# Existing channel preserved
|
||||||
|
assert "pushover_ops" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_updates_existing(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"})
|
||||||
|
assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_creates_section_if_absent():
|
||||||
|
data = {"hbd_port": 50004}
|
||||||
|
configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"})
|
||||||
|
assert "notification_channels" in data
|
||||||
|
assert "test_ch" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_removes_entry(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.delete_channel(data, "pushover_ops")
|
||||||
|
assert "pushover_ops" not in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_noop_for_missing():
|
||||||
|
data = {"notification_channels": {"ch1": {"type": "pushover"}}}
|
||||||
|
configio.delete_channel(data, "nonexistent") # must not raise
|
||||||
|
assert "ch1" in data["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_noop_when_no_section():
|
||||||
|
data = {}
|
||||||
|
configio.delete_channel(data, "anything") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_channel_persisted_after_write(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"})
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
result = configio.read_roundtrip(str(f))
|
||||||
|
assert "signal_ops" in result["notification_channels"]
|
||||||
|
assert result["notification_channels"]["signal_ops"]["user"] == "+1"
|
||||||
|
# Original channel preserved
|
||||||
|
assert "pushover_ops" in result["notification_channels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_channel_persisted_after_write(tmp_path):
|
||||||
|
f = tmp_path / ".hb.yaml"
|
||||||
|
f.write_text(SAMPLE_YAML)
|
||||||
|
data = configio.read_roundtrip(str(f))
|
||||||
|
configio.delete_channel(data, "pushover_ops")
|
||||||
|
configio.write_config(str(f), data)
|
||||||
|
result = configio.read_roundtrip(str(f))
|
||||||
|
assert "pushover_ops" not in (result.get("notification_channels") or {})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Visibility logic (mirrors http.py _visible_channels_for_user)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _visible(config, user):
|
||||||
|
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
|
||||||
|
all_channels = config.get("notification_channels") or {}
|
||||||
|
if user.get("admin"):
|
||||||
|
return set(all_channels.keys())
|
||||||
|
username = user["username"]
|
||||||
|
return {
|
||||||
|
name for name, cfg in all_channels.items()
|
||||||
|
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_VISIBILITY = {
|
||||||
|
"notification_channels": {
|
||||||
|
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
||||||
|
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
||||||
|
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
|
||||||
|
"user": "+1", "recipient": "+2"},
|
||||||
|
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_channel_visible_to_all():
|
||||||
|
for uname in ("alice", "bob", "carol"):
|
||||||
|
user = {"username": uname, "admin": False}
|
||||||
|
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_channel_visible_only_to_owner():
|
||||||
|
alice = {"username": "alice", "admin": False}
|
||||||
|
bob = {"username": "bob", "admin": False}
|
||||||
|
carol = {"username": "carol", "admin": False}
|
||||||
|
|
||||||
|
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
|
||||||
|
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
|
||||||
|
|
||||||
|
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
||||||
|
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_all_channels():
|
||||||
|
admin = {"username": "adminuser", "admin": True}
|
||||||
|
visible = _visible(CONFIG_VISIBILITY, admin)
|
||||||
|
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_owned_channel_is_public_by_default():
|
||||||
|
alice = {"username": "alice", "admin": False}
|
||||||
|
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Channel type schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_all_required_types_in_schema():
|
||||||
|
for t in ("pushover", "email", "signal", "matrix", "sms_voipms"):
|
||||||
|
assert t in settings_mod.CHANNEL_TYPE_SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_fields_have_required_keys():
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
assert "label" in schema, f"{type_id} missing label"
|
||||||
|
assert "fields" in schema, f"{type_id} missing fields"
|
||||||
|
for f in schema["fields"]:
|
||||||
|
for k in ("key", "label", "type", "required"):
|
||||||
|
assert k in f, f"{type_id} field missing {k!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_fields_use_secret_type():
|
||||||
|
"""Known secret fields must be typed 'secret' so the UI masks them."""
|
||||||
|
secret_keys = {"token", "user_key", "api_key", "api_password",
|
||||||
|
"smtp_password", "access_token"}
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
for f in schema["fields"]:
|
||||||
|
if f["key"] in secret_keys:
|
||||||
|
assert f["type"] == "secret", (
|
||||||
|
f"{type_id}.{f['key']} should be type 'secret'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_labels_not_empty():
|
||||||
|
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
|
||||||
|
assert schema["label"].strip(), f"{type_id} has empty label"
|
||||||
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
|
|||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
for s in sections:
|
for s in sections:
|
||||||
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
||||||
assert s["section_mode"] in ("form", "yaml")
|
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
|
||||||
|
|
||||||
|
|
||||||
def test_sections_have_api_section():
|
def test_sections_have_api_section():
|
||||||
@@ -45,16 +45,47 @@ def test_network_section_has_editable_fields():
|
|||||||
def test_yaml_sections_have_correct_mode():
|
def test_yaml_sections_have_correct_mode():
|
||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
||||||
assert "channels" in yaml_sections
|
assert "channels" not in yaml_sections # now uses "channels" mode
|
||||||
assert "hosts" in yaml_sections
|
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
||||||
assert "thresholds" in yaml_sections
|
assert "thresholds" in yaml_sections
|
||||||
assert "dns" in yaml_sections
|
assert "dns" in yaml_sections
|
||||||
assert yaml_sections["channels"]["api_section"] == "notification_channels"
|
|
||||||
assert yaml_sections["hosts"]["api_section"] == "hosts"
|
|
||||||
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
||||||
assert yaml_sections["dns"]["api_section"] == "dns"
|
assert yaml_sections["dns"]["api_section"] == "dns"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hosts_section_uses_hosts_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
hosts_sec = next(s for s in sections if s["id"] == "hosts")
|
||||||
|
assert hosts_sec["section_mode"] == "hosts"
|
||||||
|
assert hosts_sec["api_section"] == "hosts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channels_section_uses_channels_mode():
|
||||||
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
|
ch_sec = next(s for s in sections if s["id"] == "channels")
|
||||||
|
assert ch_sec["section_mode"] == "channels"
|
||||||
|
assert ch_sec["api_section"] == "notification_channels"
|
||||||
|
assert len(ch_sec["channels"]) == 1
|
||||||
|
ch = ch_sec["channels"][0]
|
||||||
|
assert ch["name"] == "pushover_ops"
|
||||||
|
assert ch["type"] == "pushover"
|
||||||
|
assert "owner" in ch
|
||||||
|
assert "private" in ch
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_type_schemas_exported():
|
||||||
|
assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS")
|
||||||
|
for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"):
|
||||||
|
assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS
|
||||||
|
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type]
|
||||||
|
assert "label" in schema
|
||||||
|
assert "fields" in schema
|
||||||
|
for f in schema["fields"]:
|
||||||
|
assert "key" in f
|
||||||
|
assert "type" in f
|
||||||
|
assert "required" in f
|
||||||
|
|
||||||
|
|
||||||
def test_oauth_section_exists():
|
def test_oauth_section_exists():
|
||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
oauth = next((s for s in sections if s["id"] == "oauth"), None)
|
||||||
|
|||||||
Reference in New Issue
Block a user