Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a1f412d1d | |||
| 40c44f53f1 | |||
| a6fe8546a8 | |||
| e56660454d | |||
| 9cbf0ecb13 | |||
| 313bbd37ac | |||
| f7320644f3 | |||
| 76e11b92f2 | |||
| d39c0da5fe | |||
| 832b9d04d8 | |||
| 44d5f15a67 | |||
| 37b8e35a26 | |||
| fa317a3b78 | |||
| 8729fe7038 | |||
| f4231dd5f3 | |||
| c47576637f | |||
| 2b9523ec28 | |||
| 610ad0af30 | |||
| 69b5b410ed | |||
| 8b2b0fd9d0 |
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Edit(*)",
|
|
||||||
"Bash(pytest *)",
|
|
||||||
"Bash(python *)",
|
|
||||||
"Bash(python3 *)",
|
|
||||||
"Bash(.venv/bin/pytest *)",
|
|
||||||
"Bash(npm *)",
|
|
||||||
"Bash(git *)",
|
|
||||||
"Bash(ls *)",
|
|
||||||
"Bash(cat *)",
|
|
||||||
"Bash(grep *)",
|
|
||||||
"Bash(find *)",
|
|
||||||
"Bash(mkdir *)",
|
|
||||||
"Bash(touch *)",
|
|
||||||
"Bash(uv *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
run: |
|
run: |
|
||||||
@@ -18,22 +20,38 @@ jobs:
|
|||||||
|
|
||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install --upgrade pip
|
python3 -m venv .venv
|
||||||
python3 -m pip install build twine
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install build twine
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python3 -m build
|
run: .venv/bin/python -m build
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
PREV_TAG=$(git tag --sort=-version:refname | grep -m 1 -v "^${GITHUB_REF#refs/tags/}$")
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD")
|
||||||
|
else
|
||||||
|
CHANGELOG="Initial release"
|
||||||
|
fi
|
||||||
|
# Write multiline to output
|
||||||
|
{
|
||||||
|
echo "CHANGELOG<<EOF"
|
||||||
|
echo "$CHANGELOG"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload to Gitea PyPI registry
|
- name: Upload to Gitea PyPI registry
|
||||||
env:
|
env:
|
||||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
.venv/bin/python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: actions/gitea-release-action@v1
|
uses: actions/gitea-release-action@v1
|
||||||
@@ -42,4 +60,4 @@ jobs:
|
|||||||
dist/*.whl
|
dist/*.whl
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
title: "Release ${{ steps.get_version.outputs.VERSION }}"
|
title: "Release ${{ steps.get_version.outputs.VERSION }}"
|
||||||
body: "Release version ${{ steps.get_version.outputs.VERSION }}"
|
body: "${{ steps.changelog.outputs.CHANGELOG }}"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.flake8
|
.flake8
|
||||||
.venv/
|
.venv/
|
||||||
|
.continue/
|
||||||
test/
|
test/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
+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) |
|
||||||
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
|
|||||||
└────────────────────┘ └────────────────────────────┘
|
└────────────────────┘ └────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Package:** `hbd` v5.3.4
|
**Package:** `hbd` v5.3.9
|
||||||
**Python:** 3.11+
|
**Python:** 3.11+
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.6"
|
__version__ = "5.3.9"
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ def apply_structured_section(data, section: str, values: dict) -> None:
|
|||||||
for key in _SERVER_KEYS:
|
for key in _SERVER_KEYS:
|
||||||
if key in values:
|
if key in values:
|
||||||
data[key] = values[key]
|
data[key] = values[key]
|
||||||
|
elif section == "dns":
|
||||||
|
for key in _DNS_KEYS:
|
||||||
|
if key in values:
|
||||||
|
data[key] = values[key]
|
||||||
|
else:
|
||||||
|
data.pop(key, None)
|
||||||
elif section == "users":
|
elif section == "users":
|
||||||
data["users"] = values
|
data["users"] = values
|
||||||
elif section == "hosts":
|
elif section == "hosts":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+13
-3
@@ -325,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()}
|
||||||
@@ -595,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:
|
||||||
@@ -1304,9 +1308,15 @@ async def start(
|
|||||||
attrs.pop("client_secret", None)
|
attrs.pop("client_secret", None)
|
||||||
data["oauth"] = new_oauth
|
data["oauth"] = new_oauth
|
||||||
|
|
||||||
for section in ("notification_channels", "dns"):
|
if "notification_channels" in payload:
|
||||||
if section in payload:
|
configio_mod.apply_yaml_section(data, "notification_channels", payload["notification_channels"])
|
||||||
configio_mod.apply_yaml_section(data, section, payload[section])
|
|
||||||
|
if "dns" in payload:
|
||||||
|
dns_payload = payload["dns"]
|
||||||
|
if isinstance(dns_payload, str):
|
||||||
|
configio_mod.apply_yaml_section(data, "dns", dns_payload)
|
||||||
|
else:
|
||||||
|
configio_mod.apply_structured_section(data, "dns", dns_payload)
|
||||||
|
|
||||||
if "thresholds" in payload:
|
if "thresholds" in payload:
|
||||||
tc = payload["thresholds"]
|
tc = payload["thresholds"]
|
||||||
|
|||||||
+13
-5
@@ -197,7 +197,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
# ---- Notification channels (complex, built separately) ----------------
|
# ---- Notification channels (complex, built separately) ----------------
|
||||||
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
|
_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", "")
|
||||||
@@ -276,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({
|
||||||
@@ -398,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,7 +5,68 @@
|
|||||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
||||||
|
<script>
|
||||||
|
/* Apply saved theme before first paint to avoid flash */
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var p = localStorage.getItem('hbd_theme') || 'auto';
|
||||||
|
var dark = p === 'dark' || (p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Theme variables ── */
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f8f8f8;
|
||||||
|
--surface-3: #f5f5f5;
|
||||||
|
--text: #222222;
|
||||||
|
--text-2: #333333;
|
||||||
|
--text-3: #555555;
|
||||||
|
--text-sec: #666666;
|
||||||
|
--text-muted: #888888;
|
||||||
|
--text-dim: #aaaaaa;
|
||||||
|
--text-ghost: #cccccc;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--border-2: #eeeeee;
|
||||||
|
--border-3: #f0f0f0;
|
||||||
|
--border-4: #f5f5f5;
|
||||||
|
--link: #0066cc;
|
||||||
|
--nav-bg: #ffffff;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #cccccc;
|
||||||
|
--shadow-sm: rgba(0,0,0,.08);
|
||||||
|
--shadow: rgba(0,0,0,.10);
|
||||||
|
--shadow-nav: rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #111827;
|
||||||
|
--surface: #1f2937;
|
||||||
|
--surface-2: #283447;
|
||||||
|
--surface-3: #374151;
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--text-2: #d1d5db;
|
||||||
|
--text-3: #9ca3af;
|
||||||
|
--text-sec: #9ca3af;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--text-dim: #4b5563;
|
||||||
|
--text-ghost: #374151;
|
||||||
|
--border: #374151;
|
||||||
|
--border-2: #2d3748;
|
||||||
|
--border-3: #253040;
|
||||||
|
--border-4: #1e2a38;
|
||||||
|
--link: #60a5fa;
|
||||||
|
--nav-bg: #1f2937;
|
||||||
|
--input-bg: #283447;
|
||||||
|
--input-border: #4b5563;
|
||||||
|
--shadow-sm: rgba(0,0,0,.30);
|
||||||
|
--shadow: rgba(0,0,0,.40);
|
||||||
|
--shadow-nav: rgba(0,0,0,.40);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reset / shared baseline ── */
|
/* ── Reset / shared baseline ── */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html {
|
html {
|
||||||
@@ -16,10 +77,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background: #f5f5f5;
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; }
|
h1 { font-size: 1.5em; color: var(--text-2); margin: 0 0 5px; }
|
||||||
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; }
|
h2 { font-size: 1.1em; color: var(--text-2); margin: 0 0 8px; }
|
||||||
p { margin: 0; }
|
p { margin: 0; }
|
||||||
|
|
||||||
/* Navigation bar — shared across all pages */
|
/* Navigation bar — shared across all pages */
|
||||||
@@ -29,9 +91,9 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
background: #fff;
|
background: var(--nav-bg);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
box-shadow: 0 2px 4px var(--shadow-nav);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -42,25 +104,25 @@
|
|||||||
.nav a {
|
.nav a {
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #0066cc;
|
color: var(--link);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.nav a:hover { text-decoration: underline; }
|
.nav a:hover { text-decoration: underline; }
|
||||||
.nav a.active { color: #333; font-weight: bold; }
|
.nav a.active { color: var(--text-2); font-weight: bold; }
|
||||||
.nav-user {
|
.nav-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #333;
|
color: var(--text-2);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
.nav-user:hover { background: var(--surface-2); text-decoration: none; }
|
||||||
.nav-username {
|
.nav-username {
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -81,7 +143,7 @@
|
|||||||
.nav-initials {
|
.nav-initials {
|
||||||
width: 28px; height: 28px;
|
width: 28px; height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #0066cc;
|
background: var(--link);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -106,7 +168,7 @@
|
|||||||
.nav-hamburger span {
|
.nav-hamburger span {
|
||||||
display: block;
|
display: block;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: #555;
|
background: var(--text-muted);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +180,22 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid var(--border-2);
|
||||||
order: 3;
|
order: 3;
|
||||||
}
|
}
|
||||||
.nav-links.nav-open { display: flex; }
|
.nav-links.nav-open { display: flex; }
|
||||||
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Global dark-mode: inputs ── */
|
||||||
|
html[data-theme="dark"] input:not([type=checkbox]):not([type=radio]),
|
||||||
|
html[data-theme="dark"] select,
|
||||||
|
html[data-theme="dark"] textarea {
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Pending config publish button */
|
/* Pending config publish button */
|
||||||
.nav-publish-btn {
|
.nav-publish-btn {
|
||||||
background: #e65100;
|
background: #e65100;
|
||||||
@@ -279,6 +350,17 @@
|
|||||||
setTimeout(clockTick, delay);
|
setTimeout(clockTick, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep auto-theme in sync with system setting changes */
|
||||||
|
try {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
var pref = localStorage.getItem('hbd_theme') || 'auto';
|
||||||
|
if (pref === 'auto') {
|
||||||
|
if (e.matches) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||||||
|
else { document.documentElement.removeAttribute('data-theme'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
/* Start the shared tick loop */
|
/* Start the shared tick loop */
|
||||||
clockTick();
|
clockTick();
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
|
|
||||||
/* Message styling */
|
/* Message styling */
|
||||||
#messages {
|
#messages {
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
line-height: 1.0;
|
line-height: 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +288,31 @@
|
|||||||
}
|
}
|
||||||
#ntable a.host-link { color: inherit; text-decoration: none; }
|
#ntable a.host-link { color: inherit; text-decoration: none; }
|
||||||
#ntable a.host-link:hover { text-decoration: underline; }
|
#ntable a.host-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1,
|
||||||
|
html[data-theme="dark"] h2 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] h2,
|
||||||
|
html[data-theme="dark"] .table-section,
|
||||||
|
html[data-theme="dark"] .log-section,
|
||||||
|
html[data-theme="dark"] .log-section-header { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .log-section-title { color: var(--text); }
|
||||||
|
html[data-theme="dark"] #ntable td,
|
||||||
|
html[data-theme="dark"] #ntable th { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] #ntable tr:nth-child(even) { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] #ntable tr:hover { background: #1e3a5f; }
|
||||||
|
html[data-theme="dark"] #ntable tbody tr.row-warning { background: #3a2800; }
|
||||||
|
html[data-theme="dark"] #ntable tbody tr.row-critical { background: #3a0a0a; }
|
||||||
|
html[data-theme="dark"] #ntable tbody tr.row-warning:hover { background: #4a3200; }
|
||||||
|
html[data-theme="dark"] #ntable tbody tr.row-critical:hover { background: #4a1010; }
|
||||||
|
html[data-theme="dark"] #messages .log-entry { border-bottom-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .log-ts,
|
||||||
|
html[data-theme="dark"] .log-service { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .log-info .log-level { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .log-filter-bar input,
|
||||||
|
html[data-theme="dark"] .log-filter-bar select { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .connection-modal-content { background: var(--surface); color: var(--text); }
|
||||||
</style>
|
</style>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var cnt = 0;
|
var cnt = 0;
|
||||||
@@ -540,7 +565,7 @@
|
|||||||
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();
|
applyLogFilters();
|
||||||
}
|
}
|
||||||
cnt++;
|
cnt++;
|
||||||
@@ -640,6 +665,7 @@
|
|||||||
<option value="warning">WARNING</option>
|
<option value="warning">WARNING</option>
|
||||||
<option value="critical">CRITICAL</option>
|
<option value="critical">CRITICAL</option>
|
||||||
<option value="recover">RECOVER</option>
|
<option value="recover">RECOVER</option>
|
||||||
|
<option value="unknown">UNKNOWN</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
|
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
|
|
||||||
.plugin-label {
|
.plugin-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
color: #444;
|
color: #444;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
.data-table th.center { text-align: center; }
|
.data-table th.center { text-align: center; }
|
||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 6px 10px;
|
/* padding: 6px 10px; */
|
||||||
border-top: 1px solid #e8e8e8;
|
border-top: 1px solid #e8e8e8;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@@ -379,7 +379,7 @@
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #c62828;
|
color: #c62828;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar ──────────────────────────────────────────────── */
|
/* ── Scrollbar ──────────────────────────────────────────────── */
|
||||||
@@ -394,7 +394,7 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
}
|
}
|
||||||
.info-meta {
|
.info-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -411,7 +411,48 @@
|
|||||||
}
|
}
|
||||||
.info-note { color: #888; font-style: italic; }
|
.info-note { color: #888; font-style: italic; }
|
||||||
.info-loading { color: #bbb; font-style: italic; }
|
.info-loading { color: #bbb; font-style: italic; }
|
||||||
.threshold-covers { font-size: 0.85em; color: #777; font-style: italic; }
|
.threshold-covers { font-size: 1.00em; color: #777; font-style: italic; }
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .host-card { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .host-header:hover { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .host-name { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .collapse-icon,
|
||||||
|
html[data-theme="dark"] .acc-icon { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .host-body { border-top-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .plugin-accordion { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .plugin-acc-header { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .plugin-acc-header:hover { background: var(--surface-3); }
|
||||||
|
html[data-theme="dark"] .plugin-label { color: var(--text-2); }
|
||||||
|
html[data-theme="dark"] .plugin-summary { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .data-table { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .data-table td { border-top-color: var(--border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .data-table td.key { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .data-table tbody tr:nth-child(even) { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .data-table tbody tr:hover { background: #1e3a5f; }
|
||||||
|
html[data-theme="dark"] .bar-track { background: var(--border); }
|
||||||
|
html[data-theme="dark"] .table-section-label { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .no-data,
|
||||||
|
html[data-theme="dark"] .loading { color: var(--text-dim); }
|
||||||
|
html[data-theme="dark"] .timestamp { color: var(--text-dim); border-top-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .glance-chip.neutral { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .os-label { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .host-info-section { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .info-label { color: var(--text-3); }
|
||||||
|
html[data-theme="dark"] .info-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .info-thresholds-title { color: var(--text-3); }
|
||||||
|
html[data-theme="dark"] .info-note,
|
||||||
|
html[data-theme="dark"] .info-loading,
|
||||||
|
html[data-theme="dark"] .threshold-covers { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .check-ok { background: #0d2e17; }
|
||||||
|
html[data-theme="dark"] .check-warning { background: #2e1a00; }
|
||||||
|
html[data-theme="dark"] .check-critical { background: #2e0a0a; }
|
||||||
|
html[data-theme="dark"] .check-unknown { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .check-output { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .container::-webkit-scrollbar-track { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .container::-webkit-scrollbar-thumb { background: var(--border); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -247,6 +247,56 @@
|
|||||||
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
|
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
|
||||||
.btn-sm-del:hover { background: #fce4ec; }
|
.btn-sm-del:hover { background: #fce4ec; }
|
||||||
|
|
||||||
|
/* ---- Theme picker ---- */
|
||||||
|
.theme-btns { display: flex; gap: 6px; }
|
||||||
|
.theme-btn {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: 1px solid var(--border, #e0e0e0);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-3, #f5f5f5);
|
||||||
|
color: var(--text-sec, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .88em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { border-color: var(--link, #0066cc); color: var(--link, #0066cc); }
|
||||||
|
.theme-btn.active { background: var(--link, #0066cc); color: #fff; border-color: var(--link, #0066cc); }
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .profile-card { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
|
||||||
|
html[data-theme="dark"] .profile-name { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .profile-username { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
|
||||||
|
html[data-theme="dark"] .section h2 { color: var(--text); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .settings-row { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .settings-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .settings-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .settings-empty { color: var(--text-dim); }
|
||||||
|
html[data-theme="dark"] .edit-section h4 { color: var(--text); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .edit-field label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .edit-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .channel-row { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .channel-name { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .ch-picker-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .ch-chip.selected { background: #1a3255; color: #60a5fa; }
|
||||||
|
html[data-theme="dark"] .ch-chip.available { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .ch-chip.available:hover { background: var(--border); color: var(--link); }
|
||||||
|
html[data-theme="dark"] .my-ch-card { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .my-ch-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .my-ch-name { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .host-chip.owner { background: #0d2e17; color: #66bb6a; }
|
||||||
|
html[data-theme="dark"] .host-chip.manager { background: #0d1f40; color: #64b5f6; }
|
||||||
|
html[data-theme="dark"] .host-chip.monitor { background: #1e0d30; color: #ba68c8; }
|
||||||
|
html[data-theme="dark"] .no-hosts { color: var(--text-dim); }
|
||||||
|
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
|
||||||
|
|
||||||
/* ---- Channel modal (for My Channels CRUD) ---- */
|
/* ---- Channel modal (for My Channels CRUD) ---- */
|
||||||
.ch-modal-overlay {
|
.ch-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||||
@@ -477,6 +527,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Appearance -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Theme</span>
|
||||||
|
<div class="theme-btns">
|
||||||
|
<button class="theme-btn" data-theme-val="auto" onclick="setTheme('auto')">Auto</button>
|
||||||
|
<button class="theme-btn" data-theme-val="light" onclick="setTheme('light')">Light</button>
|
||||||
|
<button class="theme-btn" data-theme-val="dark" onclick="setTheme('dark')">Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Host access -->
|
<!-- Host access -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Host Access</h2>
|
<h2>Host Access</h2>
|
||||||
@@ -523,6 +586,28 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// ---- Theme ----
|
||||||
|
function applyTheme(pref) {
|
||||||
|
var dark = pref === 'dark' ||
|
||||||
|
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||||||
|
else { document.documentElement.removeAttribute('data-theme'); }
|
||||||
|
}
|
||||||
|
function setTheme(pref) {
|
||||||
|
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
|
||||||
|
applyTheme(pref);
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||||
|
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(function() {
|
||||||
|
var pref = 'auto';
|
||||||
|
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||||
|
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// ---- Identity ----
|
// ---- Identity ----
|
||||||
async function saveIdentity() {
|
async function saveIdentity() {
|
||||||
const full_name = document.getElementById('profile-fullname').value;
|
const full_name = document.getElementById('profile-fullname').value;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
color: #444;
|
color: #444;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
transition: background 0.1s, color 0.1s;
|
transition: background 0.1s, color 0.1s;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
.channel-field {
|
.channel-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
font-size: 0.85em;
|
font-size: 1.00em;
|
||||||
border-bottom: 1px solid #f5f5f5;
|
border-bottom: 1px solid #f5f5f5;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
||||||
|
|
||||||
/* ---- Button styles ---- */
|
/* ---- Button styles ---- */
|
||||||
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; }
|
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 1.00em; cursor: pointer; }
|
||||||
.btn-primary { background: #0066cc; color: #fff; }
|
.btn-primary { background: #0066cc; color: #fff; }
|
||||||
.btn-primary:hover { background: #0055aa; }
|
.btn-primary:hover { background: #0055aa; }
|
||||||
.btn-success { background: #2a7a2a; color: #fff; }
|
.btn-success { background: #2a7a2a; color: #fff; }
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
.mpick-col:first-child { border-right: 1px solid #eee; }
|
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||||||
.mpick-item {
|
.mpick-item {
|
||||||
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
|
padding: 5px 10px; font-size: 1.00em; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||||||
}
|
}
|
||||||
@@ -456,6 +456,67 @@
|
|||||||
display: flex; justify-content: flex-end; background: #f8f8f8;
|
display: flex; justify-content: flex-end; background: #f8f8f8;
|
||||||
}
|
}
|
||||||
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Dark mode ── */
|
||||||
|
html[data-theme="dark"] h1 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a:hover { background: var(--surface-3); color: var(--link); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav a.active { background: #1a3255; color: #60a5fa; }
|
||||||
|
html[data-theme="dark"] .sidebar-toggle { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .sidebar-nav { background: var(--surface); }
|
||||||
|
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 4px var(--shadow); }
|
||||||
|
html[data-theme="dark"] .section-header { border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .section-title { color: var(--text-2); }
|
||||||
|
html[data-theme="dark"] .section-desc { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .section-footer { border-top-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .field-row { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .field-label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .field-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .field-desc { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .val-boolean.on { background: #0d2e17; color: #66bb6a; }
|
||||||
|
html[data-theme="dark"] .val-boolean.off { background: #2e0d0d; color: #ef9a9a; }
|
||||||
|
html[data-theme="dark"] .val-tag { background: #1a2d5a; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .val-empty { color: var(--text-dim); }
|
||||||
|
html[data-theme="dark"] .val-masked { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .mini-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mini-table td { border-bottom-color: var(--border-3); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .mini-table tbody tr:hover { background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .channel-card { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .channel-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .channel-name-text { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .channel-field { border-bottom-color: var(--border-4); }
|
||||||
|
html[data-theme="dark"] .channel-field-label { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .channel-field-value { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-card { border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-header { background: var(--surface-2); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .thresh-cfg-name-label { color: #60a5fa; }
|
||||||
|
html[data-theme="dark"] .crud-table th { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .crud-table td { border-bottom-color: var(--border-3); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .yaml-editor { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .pending-banner { background: #2d2400; border-color: #a08020; }
|
||||||
|
html[data-theme="dark"] .pending-banner .pending-msg { color: #e8c840; }
|
||||||
|
html[data-theme="dark"] .modal-box,
|
||||||
|
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .modal-box h3,
|
||||||
|
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
|
||||||
|
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
|
||||||
|
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .backup-row { border-bottom-color: var(--border-3); }
|
||||||
|
html[data-theme="dark"] .mpick-display { background: var(--input-bg); border-color: var(--input-border); }
|
||||||
|
html[data-theme="dark"] .mpick-display:hover { border-color: var(--link); background: var(--surface-2); }
|
||||||
|
html[data-theme="dark"] .mpick-tag { background: #1a2d5a; color: #7aa8f0; }
|
||||||
|
html[data-theme="dark"] .mpick-more,
|
||||||
|
html[data-theme="dark"] .mpick-empty { color: var(--text-muted); }
|
||||||
|
html[data-theme="dark"] .mpick-panel { background: var(--surface); border-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-panel-header { background: var(--surface-3); color: var(--text-sec); border-bottom-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-item { border-bottom-color: var(--border-4); color: var(--text); }
|
||||||
|
html[data-theme="dark"] .mpick-item-avail:hover { background: #0d2e17; }
|
||||||
|
html[data-theme="dark"] .mpick-item-sel:hover { background: #2e0d0d; }
|
||||||
|
html[data-theme="dark"] .mpick-panel-footer { background: var(--surface-2); border-top-color: var(--border); }
|
||||||
|
html[data-theme="dark"] .mpick-none { color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -742,6 +803,7 @@
|
|||||||
<th>Metric path</th><th>Op</th>
|
<th>Metric path</th><th>Op</th>
|
||||||
<th>Warning</th><th>Critical</th>
|
<th>Warning</th><th>Critical</th>
|
||||||
<th>Hysteresis</th><th>Count</th>
|
<th>Hysteresis</th><th>Count</th>
|
||||||
|
<th title="Grace period (s) — overrides global; empty = use global">Grace</th>
|
||||||
<th style="max-width:160px">Display</th>
|
<th style="max-width:160px">Display</th>
|
||||||
<th>En</th><th></th>
|
<th>En</th><th></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
@@ -766,6 +828,9 @@
|
|||||||
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
|
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
|
||||||
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
|
||||||
value="{{ m.count if m.count is not none else 1 }}"></td>
|
value="{{ m.count if m.count is not none else 1 }}"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px"
|
||||||
|
value="{{ m.grace if m.grace is not none else '' }}"
|
||||||
|
placeholder="(global)"></td>
|
||||||
<td><input type="text" class="field-input thresh-display" style="width:150px"
|
<td><input type="text" class="field-input thresh-display" style="width:150px"
|
||||||
value="{{ m.display | e }}" placeholder="(default)"></td>
|
value="{{ m.display | e }}" placeholder="(default)"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
|
||||||
@@ -816,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 }}"
|
||||||
@@ -1019,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;
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1539,7 @@
|
|||||||
const crit = row.querySelector('.thresh-crit')?.value;
|
const crit = row.querySelector('.thresh-crit')?.value;
|
||||||
const hyst = row.querySelector('.thresh-hyst')?.value;
|
const hyst = row.querySelector('.thresh-hyst')?.value;
|
||||||
const count = row.querySelector('.thresh-count')?.value;
|
const count = row.querySelector('.thresh-count')?.value;
|
||||||
|
const grace = row.querySelector('.thresh-grace')?.value;
|
||||||
const display = row.querySelector('.thresh-display')?.value || '';
|
const display = row.querySelector('.thresh-display')?.value || '';
|
||||||
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
|
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
|
||||||
const entry = { operator: op, enabled: enabled };
|
const entry = { operator: op, enabled: enabled };
|
||||||
@@ -1474,6 +1547,7 @@
|
|||||||
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
|
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
|
||||||
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
|
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
|
||||||
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
|
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
|
||||||
|
if (grace !== '' && grace !== undefined) entry.grace = parseFloat(grace);
|
||||||
if (display) entry.display = display;
|
if (display) entry.display = display;
|
||||||
metrics[metric] = entry;
|
metrics[metric] = entry;
|
||||||
});
|
});
|
||||||
@@ -1525,6 +1599,7 @@
|
|||||||
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
|
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
|
||||||
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
|
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
|
||||||
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
|
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
|
||||||
|
<td><input type="number" class="field-input thresh-grace" step="any" min="0" style="width:60px" placeholder="(global)"></td>
|
||||||
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
|
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
|
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
|
||||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
+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)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.6"
|
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"
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.3.6"
|
__version__ = "5.3.9"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
|
|||||||
Reference in New Issue
Block a user