Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0443293e9 | |||
| 39670f4e63 | |||
| 2e88ee2269 | |||
| 2ef7d473c3 | |||
| 862a9cdea0 | |||
| 9351938b15 | |||
| b6ef2fe065 | |||
| d5d2f066b3 | |||
| d9563392c3 | |||
| 5f090b9d96 | |||
| 3cc1d92eb4 | |||
| 2ddba203df | |||
| 8a1f412d1d | |||
| 40c44f53f1 | |||
| a6fe8546a8 | |||
| e56660454d | |||
| 9cbf0ecb13 | |||
| 313bbd37ac | |||
| f7320644f3 | |||
| 76e11b92f2 | |||
| d39c0da5fe | |||
| 832b9d04d8 | |||
| 44d5f15a67 | |||
| 37b8e35a26 | |||
| fa317a3b78 | |||
| 8729fe7038 | |||
| f4231dd5f3 | |||
| c47576637f | |||
| 2b9523ec28 | |||
| 610ad0af30 | |||
| 69b5b410ed | |||
| 8b2b0fd9d0 |
@@ -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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
run: |
|
||||
@@ -18,22 +20,38 @@ jobs:
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install build twine
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python3 -m build
|
||||
run: .venv/bin/python -m build
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
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
|
||||
env:
|
||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
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
|
||||
uses: actions/gitea-release-action@v1
|
||||
@@ -42,4 +60,4 @@ jobs:
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
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
|
||||
.flake8
|
||||
.venv/
|
||||
.continue/
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here, organized by release.
|
||||
|
||||
## [5.3.10]
|
||||
|
||||
### Added
|
||||
- clear stale plugin data and persist OAuth users to config
|
||||
- auto-scale CPU history graph Y axis
|
||||
- add CPU usage history graph to CPU Monitor section
|
||||
|
||||
### Fixed
|
||||
- remove bak file in bumpminor.sh
|
||||
|
||||
---
|
||||
|
||||
## [5.3.9]
|
||||
|
||||
### Added
|
||||
- auto-update CHANGELOG and README in bumpminor.sh
|
||||
|
||||
---
|
||||
|
||||
## [5.3.8]
|
||||
|
||||
### Added
|
||||
- Wiki home page with overview and getting started guide
|
||||
|
||||
### Fixed
|
||||
- Release workflow: use `GITHUB_REF`/`GITHUB_OUTPUT` (Gitea Actions uses GitHub-compatible variable names)
|
||||
- Release workflow: replace `head -1` with `grep -m 1` to avoid SIGPIPE (exit 141) in changelog step
|
||||
|
||||
---
|
||||
|
||||
## [5.3.7]
|
||||
|
||||
### Added
|
||||
- Dark mode with light/dark/auto theme setting
|
||||
- UNKNOWN level filter in Log of Events
|
||||
- Per-metric grace period input in threshold settings
|
||||
- Replace Dynamic DNS YAML editor with a web form
|
||||
- Sort hosts, thresholds, and channels alphabetically on settings page
|
||||
- Suppress alerts for unwatched hosts
|
||||
|
||||
### Fixed
|
||||
- Preserve log message order when replaying history on connect
|
||||
|
||||
---
|
||||
|
||||
## [5.3.6]
|
||||
|
||||
### Added
|
||||
- MIT license
|
||||
|
||||
### Fixed
|
||||
- Correct ZFS pool status threshold operator and add per-metric grace
|
||||
- Normalize email and domain fields
|
||||
- Move dependencies back under `[project]` in pyproject.toml
|
||||
|
||||
---
|
||||
|
||||
## [5.3.4]
|
||||
|
||||
### Fixed
|
||||
- Run full reload after HTTP config publish, not just `config.reload()`
|
||||
|
||||
---
|
||||
|
||||
## [5.3.3]
|
||||
|
||||
### Added
|
||||
- Replace YAML threshold editor with a form-based UI
|
||||
- Replace multi-select fields with dual-panel picker on settings page
|
||||
- Nav bar button to publish pending config changes
|
||||
- Host, level, and message filters in Log of Events
|
||||
|
||||
### Fixed
|
||||
- Remove container max-width; stop stretching inputs on settings page
|
||||
|
||||
### Removed
|
||||
- Legacy `dyndnshosts`/`drophosts` config keys
|
||||
|
||||
---
|
||||
|
||||
## [5.3.2]
|
||||
|
||||
### Added
|
||||
- Retry DNS resolution indefinitely; add `-4`/`-6` address-family flags to `hbc` and `hbc_mini`
|
||||
- Replace YAML hosts editor with form-based CRUD table
|
||||
- Replace YAML notification channel editor with form-based UI
|
||||
|
||||
### Fixed
|
||||
- Support list-valued `threshold_config` in hosts table
|
||||
- Derive hosts threshold config list from config file keys
|
||||
- Replace channel checkboxes in Users table with multi-select
|
||||
- Support plugin-level `enabled: false` in threshold config
|
||||
- Always populate glance strip for all hosts on page load
|
||||
- Fetch host info on initial page load
|
||||
|
||||
---
|
||||
|
||||
## [5.3.1]
|
||||
|
||||
### Added
|
||||
- Host info section in Host Overview (fetched and rendered on card expand)
|
||||
- `GET /api/0/hosts/{hostname}/info` endpoint
|
||||
- Show suffix-matched metric coverage in host info threshold table
|
||||
- Move `hbc_version` and `hbc_type` out of `os_info` into the host info section
|
||||
|
||||
### Fixed
|
||||
- Correct `THRESHOLD_DEFAULTS` metric keys and add missing defaults
|
||||
|
||||
---
|
||||
|
||||
## [5.3.0]
|
||||
|
||||
### Added
|
||||
- Profile page self-service: change identity, password, and notification channels
|
||||
- Settings page editor with form sections, YAML editors, stage/publish/rollback workflow
|
||||
- Config read API: `GET /api/0/config`, `/section/{name}`, `/backups`
|
||||
- Config write API: `POST /api/0/config`, `POST /api/0/config/rollback`
|
||||
- `configio` module for comment-preserving YAML round-trip writes
|
||||
- Multi-provider OAuth2 login page and generic provider routes
|
||||
- Log login/logout events to the event log with auth source
|
||||
|
||||
### Fixed
|
||||
- ZFS monitor alerts dropped on restart with wildcard pool thresholds
|
||||
- Preserve OAuth users across config reload
|
||||
- Config API error handling, consistent 403 messages, deduplicated key lists
|
||||
- Validate password body type; coerce `notification_channels` to strings in profile API
|
||||
- Preserve OAuth `client_secret` on roundtrip; harden rollback path validation
|
||||
|
||||
---
|
||||
|
||||
## [5.2.6]
|
||||
|
||||
### Added
|
||||
- Alerts host-filter field with URL query parameter and notify URL
|
||||
- Optional logo on Gitea OAuth login button
|
||||
|
||||
### Fixed
|
||||
- Show human-readable duration in re-notification messages
|
||||
|
||||
---
|
||||
|
||||
## [5.2.5]
|
||||
|
||||
### Added
|
||||
- Alert CRITICAL on degraded or suspended ZFS pools (ONLINE=OK, DEGRADED=WARNING, all else=CRITICAL)
|
||||
- Sign in with Gitea button on login page with OAuth2 redirect/callback routes
|
||||
- OAuth2 CSRF state management
|
||||
- Host owner shown in glance strip for admin users
|
||||
- C port of `hbc_mini` (single-file client in `scripts/c/`)
|
||||
|
||||
### Fixed
|
||||
- Use `base_url` config for OAuth redirect URI to handle reverse proxy deployments
|
||||
- Preserve OAuth users across config reload
|
||||
- Escape HTML in login page error display
|
||||
|
||||
---
|
||||
|
||||
## [5.2.4]
|
||||
|
||||
### Added
|
||||
- `hbc`/`hbc_mini`: `owner` config field included in `os_info`; server applies to host record
|
||||
- Server requests InfoPlugin refresh when a host has no plugin data
|
||||
- Event log stores structured dicts; filter by user
|
||||
|
||||
### Fixed
|
||||
- Strip `_status_code` suffix from displayed metric names in threshold alerts
|
||||
- Use plain URL in Mattermost plugin metrics link
|
||||
- Fall back to `default_owner` when `os_info` has no owner
|
||||
|
||||
---
|
||||
|
||||
## [5.2.3]
|
||||
|
||||
### Added
|
||||
- `hbc`/`hbc_mini`: log name and version at startup
|
||||
- Show metric name inline with hostname in alerts and notifications
|
||||
|
||||
### Fixed
|
||||
- Send shutdown message only if a boot message was previously sent; suppress both on restart
|
||||
|
||||
---
|
||||
|
||||
## [5.2.2]
|
||||
|
||||
### Fixed
|
||||
- Retry connection on network error instead of permanently dropping it
|
||||
- Silence `aiohttp.access` log; strip plugin prefix in alerts UI
|
||||
|
||||
---
|
||||
|
||||
## [5.2.1]
|
||||
|
||||
### Fixed
|
||||
- Threshold and logging improvements
|
||||
|
||||
---
|
||||
|
||||
## [5.2.0]
|
||||
|
||||
### Added
|
||||
- `nagios` operator for direct exit-code severity mapping
|
||||
|
||||
### Fixed
|
||||
- Always show `THRESHOLD_DEFAULTS` in Settings threshold config
|
||||
|
||||
---
|
||||
|
||||
## [5.1.21]
|
||||
|
||||
### Added
|
||||
- `nagios_runner` improvements and alerts page fixes
|
||||
|
||||
---
|
||||
|
||||
## [5.1.20]
|
||||
|
||||
### Added
|
||||
- Generic threshold matching for `nagios_runner` with `{check_name}` display support
|
||||
|
||||
### Fixed
|
||||
- Reduce default hysteresis from 10% to 2%
|
||||
- Show recovery threshold in alerts UI
|
||||
|
||||
---
|
||||
|
||||
## [5.1.19]
|
||||
|
||||
### Added
|
||||
- Exclude ZFS ARC from `memory_percent`
|
||||
- Add `uptime_seconds` to `cpu_monitor`
|
||||
|
||||
### Fixed
|
||||
- Send boot/shutdown message on the first open connection, not blindly on the first in list
|
||||
|
||||
---
|
||||
|
||||
## [5.1.18]
|
||||
|
||||
### Added
|
||||
- Fetch-based Update/Delete buttons with toast notifications on Host Overview
|
||||
|
||||
### Fixed
|
||||
- Settings thresholds show correct per-config metrics; miscellaneous `hbc` fixes
|
||||
|
||||
---
|
||||
|
||||
## [5.1.17]
|
||||
|
||||
### Added
|
||||
- Owner Update/Delete buttons on Host Overview; purge stale alerts on reload
|
||||
- Retry `AsyncConnection.open()` indefinitely; drop IPv6 only on early startup failure
|
||||
- Alert pie chart in the nav bar
|
||||
|
||||
### Fixed
|
||||
- Make Alerts page scrollable
|
||||
|
||||
---
|
||||
|
||||
## [5.1.16]
|
||||
|
||||
### Added
|
||||
- Generic `ping_monitor` thresholds; round RTT to nearest ms
|
||||
|
||||
---
|
||||
|
||||
## [5.1.15]
|
||||
|
||||
### Added
|
||||
- Link hostnames in Live Dashboard to Host Overview
|
||||
- Threshold Configurations section on settings page
|
||||
|
||||
### Fixed
|
||||
- Suppress notifications on alert de-escalation (e.g. CRITICAL→WARNING)
|
||||
- Suppress recover messages for down durations under 4 seconds
|
||||
|
||||
---
|
||||
|
||||
## [5.1.14]
|
||||
|
||||
### Added
|
||||
- ZFS pool renderer in Host Overview
|
||||
|
||||
---
|
||||
|
||||
## [5.1.13]
|
||||
|
||||
### Added
|
||||
- ZFS monitor plugin
|
||||
- Host-level watch flag to suppress notifications
|
||||
- Filter Live Dashboard and Host Overview by owner/manager
|
||||
- Composable `threshold_config` list for per-host threshold layering
|
||||
- Restart on SIGHUP in `hbc` and `hbc_mini`
|
||||
|
||||
### Fixed
|
||||
- Mask `api_password` and `access_token` in settings page
|
||||
|
||||
---
|
||||
|
||||
## [5.1.12]
|
||||
|
||||
Internal release — no user-visible changes.
|
||||
|
||||
---
|
||||
|
||||
## [5.1.11]
|
||||
|
||||
### Fixed
|
||||
- Install under Docker
|
||||
- Clean up install script
|
||||
|
||||
---
|
||||
|
||||
## [5.1.10]
|
||||
|
||||
### Fixed
|
||||
- Synchronize version in `hbc_mini`
|
||||
- Install script no longer overwrites itself
|
||||
|
||||
---
|
||||
|
||||
## [5.1.9]
|
||||
|
||||
### Added
|
||||
- Install `hbc_mini` via package or install script
|
||||
|
||||
---
|
||||
|
||||
## [5.1.8]
|
||||
|
||||
### Added
|
||||
- Track `hbc` type and version
|
||||
|
||||
### Fixed
|
||||
- Nav bar position
|
||||
|
||||
---
|
||||
|
||||
## [5.1.7]
|
||||
|
||||
### Added
|
||||
- `hbc_mini`: single-file heartbeat client
|
||||
|
||||
### Fixed
|
||||
- Drop dead connections on protocol error
|
||||
|
||||
---
|
||||
|
||||
## [5.1.6]
|
||||
|
||||
### Fixed
|
||||
- Simplify event log usage; fix argument handling
|
||||
|
||||
---
|
||||
|
||||
## [5.1.5]
|
||||
|
||||
### Added
|
||||
- Update `hbc` via `hb_install.sh` instead of code patching
|
||||
|
||||
---
|
||||
|
||||
## [5.1.4]
|
||||
|
||||
### Added
|
||||
- Redesign Plugin Metrics page as Host Overview
|
||||
|
||||
---
|
||||
|
||||
## [5.1.3]
|
||||
|
||||
### Added
|
||||
- Validate absolute command paths at `nagios_runner` init
|
||||
- Async subprocess in `nagios_runner` with stderr capture and signal handling
|
||||
- `skip_reason` field on `Plugin`; surface in `PluginLoader` init messaging
|
||||
|
||||
### Fixed
|
||||
- Use `shlex.split()` for `nagios_runner` path validation to handle quoted paths
|
||||
- Reconfigure logging to syslog after `daemonize()`
|
||||
|
||||
---
|
||||
|
||||
## [5.1.2]
|
||||
|
||||
### Fixed
|
||||
- Plugin config lookup shadowed by `CLIENT_DEFAULTS` plugins key
|
||||
- Apply grace period to all threshold alerts before logging/notifying
|
||||
- RECOVER routing: use consistent level name and route via alerted channel
|
||||
- Early reminder notifications and lost recovery notifications
|
||||
- Non-alerting of overdue hosts
|
||||
|
||||
### Added
|
||||
- Swiss clock widget in the UI
|
||||
|
||||
---
|
||||
|
||||
## [5.1.1]
|
||||
|
||||
### Added
|
||||
- SMS and Matrix notification channels
|
||||
- CLI commands `stop`, `restart`, and `reload` for `hbd`
|
||||
- WebSocket endpoint at `http://.../ws`
|
||||
- Mobile HTML pages
|
||||
|
||||
### Fixed
|
||||
- Profile not updating
|
||||
- Sortable columns in tables
|
||||
|
||||
---
|
||||
|
||||
## [5.1.0]
|
||||
|
||||
### Added
|
||||
- Ping monitor plugin
|
||||
- Persist state to pickle file; restart timers on server restart
|
||||
- SIGHUP config reload for `hbd`
|
||||
- Renotify on CRITICAL only; persistent user sessions
|
||||
- RTT count threshold
|
||||
|
||||
### Fixed
|
||||
- Bogus notification on new clients
|
||||
- Show "overdue" in alerts instead of null
|
||||
|
||||
---
|
||||
|
||||
## [5.0.12]
|
||||
|
||||
### Added
|
||||
- User management and settings page
|
||||
|
||||
---
|
||||
|
||||
## [5.0.10]
|
||||
|
||||
### Added
|
||||
- Publish package to Gitea PyPI registry
|
||||
|
||||
---
|
||||
|
||||
## [5.0.9]
|
||||
|
||||
### Added
|
||||
- Use `SO_TIMESTAMP` for RTT measurement (Linux, FreeBSD, macOS)
|
||||
- Persist state to pickle file; restart timers on restart
|
||||
|
||||
---
|
||||
|
||||
## [5.0.6]
|
||||
|
||||
### Added
|
||||
- Major codebase refactoring: restructured into client/server components
|
||||
- Per-client threshold configuration
|
||||
- Display and acknowledge alerts in the UI
|
||||
- Proper `hbc` termination; `hbd` config reloadable at runtime
|
||||
@@ -0,0 +1,210 @@
|
||||
# Heartbeat
|
||||
|
||||
Heartbeat is a lightweight host monitoring system built around a simple idea: each machine you want to monitor runs a small client (`hbc`) that sends a UDP "heartbeat" packet to a central server (`hbd`) on a regular interval. If a heartbeat stops arriving, you get notified. Alongside reachability, clients can ship system metrics — CPU, memory, disk, network — and the server will alert you when any of those cross a threshold.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
[ monitored host ] [ your server ]
|
||||
┌─────────────┐ UDP 50003 ┌────────────────────────┐
|
||||
│ hbc │ ────────────> │ hbd │
|
||||
│ │ │ host state tracking │
|
||||
│ plugins: │ <──────────── │ threshold alerting │
|
||||
│ cpu, mem, │ ACK / CMD │ notifications │
|
||||
│ disk, ... │ │ web dashboard + API │
|
||||
└─────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
- **hbd** — the server daemon. Tracks which hosts are alive, evaluates metric thresholds, fires notifications, serves the web dashboard and REST API.
|
||||
- **hbc** — the client. Sends heartbeats and plugin data over UDP. Runs on any Linux/BSD/macOS host.
|
||||
- **hbc_mini** — a zero-dependency single-file alternative (`hbc_mini.py` or `hbc_mini.c`) for hosts where you can't install Python packages.
|
||||
|
||||
Notifications can go to Pushover, email, Mattermost, Matrix, Signal, or VoIP.ms SMS. The dashboard shows host connectivity, RTT graphs, active alerts, and per-host plugin metrics in real time via WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
This tutorial sets up a server on one machine and a client on a second machine. You'll end up with a working dashboard and your first host being monitored.
|
||||
|
||||
### 1. Install the server
|
||||
|
||||
On the machine that will run `hbd`:
|
||||
|
||||
```bash
|
||||
git clone https://git.wrede.ca/andreas/heartbeat.git
|
||||
cd heartbeat
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install .
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
|
||||
```bash
|
||||
hbd --help
|
||||
```
|
||||
|
||||
### 2. Create a server config
|
||||
|
||||
Create `~/.hb.yaml`:
|
||||
|
||||
```yaml
|
||||
hb_port: 50003 # UDP port — clients send heartbeats here
|
||||
hbd_port: 50004 # HTTP port — web dashboard and API
|
||||
ws_port: 50005 # WebSocket port — live dashboard updates
|
||||
|
||||
interval: 20 # Expected heartbeat interval (seconds)
|
||||
grace: 2 # Seconds of slack before a host is considered overdue
|
||||
|
||||
pickfile: ~/.hb.pick
|
||||
pidfile: ~/.hb.pid
|
||||
logfile: ~/.hb.log
|
||||
```
|
||||
|
||||
That's enough to get started. No hosts, no users, no notifications needed yet — the server will accept any client that connects.
|
||||
|
||||
### 3. Start the server
|
||||
|
||||
```bash
|
||||
hbd serve -c ~/.hb.yaml -f -v
|
||||
```
|
||||
|
||||
`-f` keeps it in the foreground so you can watch the log. You should see:
|
||||
|
||||
```
|
||||
Heartbeat daemon starting on UDP :50003, HTTP :50004, WS :50005
|
||||
```
|
||||
|
||||
Open `http://your-server:50004/live` in a browser. The dashboard is empty for now.
|
||||
|
||||
### 4. Install the client on a host to monitor
|
||||
|
||||
On the machine you want to monitor (must be able to reach the server on UDP 50003):
|
||||
|
||||
```bash
|
||||
pip install hbd # or: copy scripts/hbc_mini.py if you can't install packages
|
||||
```
|
||||
|
||||
#### Quick start — no config file
|
||||
|
||||
```bash
|
||||
hbc your-server.example.com
|
||||
```
|
||||
|
||||
Within a few seconds the server log will show the host checking in, and it will appear on the dashboard.
|
||||
|
||||
#### With a config file
|
||||
|
||||
Create `~/.hbc.yaml` on the client host:
|
||||
|
||||
```yaml
|
||||
hb_port: 50003
|
||||
interval: 10 # Send a heartbeat every 10 seconds
|
||||
|
||||
plugins:
|
||||
cpu_monitor:
|
||||
interval: 60
|
||||
memory_monitor:
|
||||
interval: 60
|
||||
disk_monitor:
|
||||
interval: 60
|
||||
```
|
||||
|
||||
Then start the client:
|
||||
|
||||
```bash
|
||||
hbc -c ~/.hbc.yaml your-server.example.com
|
||||
```
|
||||
|
||||
Send a boot message at startup so the server logs when the host came up:
|
||||
|
||||
```bash
|
||||
hbc -b -c ~/.hbc.yaml your-server.example.com
|
||||
```
|
||||
|
||||
Run as a daemon (logs go to syslog):
|
||||
|
||||
```bash
|
||||
hbc -d -b -c ~/.hbc.yaml your-server.example.com
|
||||
```
|
||||
|
||||
### 5. View the dashboard
|
||||
|
||||
Open `http://your-server:50004/live`. You'll see the monitored host, its last heartbeat time, and RTT. Click the host name to see plugin metrics.
|
||||
|
||||
Navigate to `/plugins/<hostname>` for CPU, memory, and disk graphs.
|
||||
|
||||
### 6. Add a notification channel (optional)
|
||||
|
||||
Edit `~/.hb.yaml` on the server:
|
||||
|
||||
```yaml
|
||||
notification_channels:
|
||||
pushover_ops:
|
||||
type: pushover
|
||||
token: YOUR_APP_TOKEN
|
||||
user: YOUR_USER_KEY
|
||||
|
||||
users:
|
||||
alice:
|
||||
password: pbkdf2:sha256:... # generate: hbd passwd alice
|
||||
admin: true
|
||||
notification_channels: [pushover_ops]
|
||||
|
||||
default_owner: alice
|
||||
```
|
||||
|
||||
Generate the password hash:
|
||||
|
||||
```bash
|
||||
hbd passwd alice
|
||||
```
|
||||
|
||||
Paste the output into the config, then reload:
|
||||
|
||||
```bash
|
||||
hbd reload
|
||||
```
|
||||
|
||||
Test the channel:
|
||||
|
||||
```bash
|
||||
hbd notify
|
||||
```
|
||||
|
||||
### 7. Set a threshold alert (optional)
|
||||
|
||||
Add to `~/.hb.yaml`:
|
||||
|
||||
```yaml
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
disk_monitor:
|
||||
partitions:
|
||||
/:
|
||||
percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
```
|
||||
|
||||
Reload: `hbd reload`. The server will now alert when a monitored host crosses these values.
|
||||
|
||||
---
|
||||
|
||||
## What's next
|
||||
|
||||
| Topic | Where to look |
|
||||
|---|---|
|
||||
| Full server config reference | [README — Server](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#server-hbd) |
|
||||
| Client options and all plugins | [README — Client](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#client-hbc) |
|
||||
| Threshold alerting details | [THRESHOLD_ALERTING.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/THRESHOLD_ALERTING.md) |
|
||||
| Notification channels | [NOTIFICATIONS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NOTIFICATIONS.md) |
|
||||
| User accounts and roles | [USERS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/USERS.md) |
|
||||
| Writing a custom plugin | [PLUGIN_DEVELOPMENT.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/PLUGIN_DEVELOPMENT.md) |
|
||||
| Nagios check integration | [NAGIOS_INTEGRATION.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NAGIOS_INTEGRATION.md) |
|
||||
| REST API | [HTTP_API.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/HTTP_API.md) |
|
||||
| Zero-dependency client | [README — hbc_mini](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#hbc_mini--zero-dependency-client) |
|
||||
@@ -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.10
|
||||
**Python:** 3.11+
|
||||
|
||||
### 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__"]
|
||||
__version__ = "5.3.6"
|
||||
__version__ = "5.3.10"
|
||||
|
||||
@@ -88,6 +88,12 @@ def apply_structured_section(data, section: str, values: dict) -> None:
|
||||
for key in _SERVER_KEYS:
|
||||
if key in values:
|
||||
data[key] = values[key]
|
||||
elif section == "dns":
|
||||
for key in _DNS_KEYS:
|
||||
if key in values:
|
||||
data[key] = values[key]
|
||||
else:
|
||||
data.pop(key, None)
|
||||
elif section == "users":
|
||||
data["users"] = values
|
||||
elif section == "hosts":
|
||||
|
||||
+33
-1
@@ -286,7 +286,7 @@ class Host:
|
||||
Host.hosts[name] = self
|
||||
self.num = num
|
||||
self.dyn = False
|
||||
self.watched = True
|
||||
self.watched = False
|
||||
self.upcount = 0
|
||||
self.interval = 0
|
||||
self.doesack = -1
|
||||
@@ -297,6 +297,8 @@ class Host:
|
||||
self.plugin_retention = 100 # Keep last N samples per plugin
|
||||
# Alert state tracking: {metric_path: AlertState}
|
||||
self.alert_states = {}
|
||||
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
|
||||
self.plugin_timers = {}
|
||||
# User access control
|
||||
self.owner: str | None = None # username of owner
|
||||
self.managers: list = [] # usernames with manager role
|
||||
@@ -483,6 +485,8 @@ class Host:
|
||||
self.managers = []
|
||||
if not hasattr(self, "monitors"):
|
||||
self.monitors = []
|
||||
if not hasattr(self, "plugin_timers"):
|
||||
self.plugin_timers = {}
|
||||
|
||||
pass
|
||||
|
||||
@@ -542,6 +546,34 @@ class Host:
|
||||
"""
|
||||
return self.plugin_data
|
||||
|
||||
def reset_plugin_timer(self, plugin_name, timeout_seconds, callback):
|
||||
"""Reset the stale-data timer for a plugin.
|
||||
|
||||
If no new PLG data arrives within timeout_seconds, callback(host, plugin_name)
|
||||
is called so the caller can clear history and alerts.
|
||||
"""
|
||||
import asyncio
|
||||
existing = self.plugin_timers.get(plugin_name)
|
||||
if existing and not existing.cancelled():
|
||||
existing.cancel()
|
||||
|
||||
async def _fire():
|
||||
await callback(self, plugin_name)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
self.plugin_timers[plugin_name] = loop.call_later(
|
||||
timeout_seconds, lambda: asyncio.create_task(_fire())
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def cancel_plugin_timer(self, plugin_name):
|
||||
"""Cancel the stale timer for a plugin, if any."""
|
||||
handle = self.plugin_timers.pop(plugin_name, None)
|
||||
if handle and not handle.cancelled():
|
||||
handle.cancel()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User-role helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+30
-3
@@ -325,6 +325,8 @@ async def start(
|
||||
from .threshold import AlertLevel
|
||||
critical = warning = ok = 0
|
||||
for host in hbdclass.Host.hosts.values():
|
||||
if not host.watched:
|
||||
continue
|
||||
if not _can_operate_host(user, host):
|
||||
continue
|
||||
levels = {s.level for s in host.alert_states.values()}
|
||||
@@ -595,6 +597,8 @@ async def start(
|
||||
all_alerts = []
|
||||
|
||||
for hostname, host in hbdclass.Host.hosts.items():
|
||||
if not host.watched:
|
||||
continue
|
||||
if not _can_view_host(user, host):
|
||||
continue
|
||||
if threshold_checker:
|
||||
@@ -1178,6 +1182,23 @@ async def start(
|
||||
profile["full_name"],
|
||||
profile["avatar_url"],
|
||||
)
|
||||
# Persist new OAuth users to the config file so they survive restarts.
|
||||
# Only write when the user isn't already in the config's users section.
|
||||
if _config_path and not (config.get("users") or {}).get(user.username):
|
||||
try:
|
||||
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||
if not disk_data.get("users"):
|
||||
disk_data["users"] = {}
|
||||
disk_data["users"][user.username] = {
|
||||
k: v for k, v in [
|
||||
("full_name", user.full_name),
|
||||
("avatar", user.avatar),
|
||||
] if v
|
||||
}
|
||||
configio_mod.write_config(_config_path, disk_data)
|
||||
logger.info("Persisted OAuth user %r to config", user.username)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist OAuth user %r to config: %s", user.username, exc)
|
||||
session_token = users_mod.create_session(user.username)
|
||||
eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}")
|
||||
resp = web.HTTPFound("/")
|
||||
@@ -1304,9 +1325,15 @@ async def start(
|
||||
attrs.pop("client_secret", None)
|
||||
data["oauth"] = new_oauth
|
||||
|
||||
for section in ("notification_channels", "dns"):
|
||||
if section in payload:
|
||||
configio_mod.apply_yaml_section(data, section, payload[section])
|
||||
if "notification_channels" in payload:
|
||||
configio_mod.apply_yaml_section(data, "notification_channels", payload["notification_channels"])
|
||||
|
||||
if "dns" in payload:
|
||||
dns_payload = payload["dns"]
|
||||
if isinstance(dns_payload, str):
|
||||
configio_mod.apply_yaml_section(data, "dns", dns_payload)
|
||||
else:
|
||||
configio_mod.apply_structured_section(data, "dns", dns_payload)
|
||||
|
||||
if "thresholds" in payload:
|
||||
tc = payload["thresholds"]
|
||||
|
||||
@@ -140,7 +140,9 @@ def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
|
||||
if not token or not user:
|
||||
logger.warning("pushover: missing token or user")
|
||||
return False
|
||||
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body}
|
||||
body = "%s: %s" % (notif.title, notif.body)
|
||||
title = ""
|
||||
params: dict = {"token": token, "user": user, "title": title, "message": body}
|
||||
if channel_cfg.get("sound"):
|
||||
params["sound"] = channel_cfg["sound"]
|
||||
if notif.url:
|
||||
|
||||
+13
-5
@@ -197,7 +197,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||
# ---- Notification channels (complex, built separately) ----------------
|
||||
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
|
||||
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):
|
||||
continue
|
||||
ch_type = ch_cfg.get("type", "")
|
||||
@@ -276,7 +276,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||
|
||||
# ---- Hosts summary ----------------------------------------------------
|
||||
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):
|
||||
continue
|
||||
hosts_list.append({
|
||||
@@ -398,10 +398,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||
{
|
||||
"id": "dns",
|
||||
"title": "Dynamic DNS",
|
||||
"description": "nsupdate-based DNS registration — edit raw YAML.",
|
||||
"section_mode": "yaml",
|
||||
"description": "nsupdate-based DNS registration via nsupdate(8).",
|
||||
"section_mode": "form",
|
||||
"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",
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
/* Slightly larger tap targets in tables */
|
||||
#ntable td, #ntable th {
|
||||
padding: 4px 6px !important;
|
||||
font-size: 0.82em !important;
|
||||
font-size: 1.00em !important;
|
||||
}
|
||||
|
||||
/* Cards on plugin/alerts pages */
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -100,6 +100,19 @@
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
.summary-label {
|
||||
color: #666;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -221,7 +221,7 @@
|
||||
|
||||
.alert-duration {
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
@@ -238,7 +238,7 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -293,7 +293,7 @@
|
||||
.refresh-info {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
@@ -305,6 +305,31 @@
|
||||
text-align: right;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
html[data-theme="dark"] h1 { color: var(--text); }
|
||||
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
||||
html[data-theme="dark"] .summary-card { background: var(--surface); }
|
||||
html[data-theme="dark"] .summary-label { color: var(--text-sec); }
|
||||
html[data-theme="dark"] .filters { background: var(--surface); }
|
||||
html[data-theme="dark"] .filter-label { color: var(--text-sec); }
|
||||
html[data-theme="dark"] .filter-button { background: var(--surface-2); border-color: var(--border); color: var(--text); }
|
||||
html[data-theme="dark"] .filter-button.active { background: #2196f3; color: #fff; border-color: #2196f3; }
|
||||
html[data-theme="dark"] .filter-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||
html[data-theme="dark"] .alerts-container { background: var(--surface); }
|
||||
html[data-theme="dark"] .alert-item { background: var(--surface-2); }
|
||||
html[data-theme="dark"] .alert-item.acknowledged { background: var(--surface-3); }
|
||||
html[data-theme="dark"] .alert-item.critical { background: #2e0a0a; border-left-color: #f44336; }
|
||||
html[data-theme="dark"] .alert-item.warning { background: #2e1a00; border-left-color: #ff9800; }
|
||||
html[data-theme="dark"] .alert-item.unknown { background: var(--surface-2); }
|
||||
html[data-theme="dark"] .alert-hostname { color: var(--link); }
|
||||
html[data-theme="dark"] .alert-details { color: var(--text-sec); }
|
||||
html[data-theme="dark"] .alert-value { color: var(--text); }
|
||||
html[data-theme="dark"] .alert-duration { color: var(--text-muted); }
|
||||
html[data-theme="dark"] .last-update { color: var(--text-sec); }
|
||||
html[data-theme="dark"] .refresh-info { color: var(--text-muted); border-top-color: var(--border); }
|
||||
html[data-theme="dark"] .no-alerts,
|
||||
html[data-theme="dark"] .loading { color: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,7 +5,68 @@
|
||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||
<title>{{ title }}</title>
|
||||
{% 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>
|
||||
/* ── 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 ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html {
|
||||
@@ -16,10 +77,11 @@
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
padding-top: 60px;
|
||||
background: #f5f5f5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; }
|
||||
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; }
|
||||
h1 { font-size: 1.5em; color: var(--text-2); margin: 0 0 5px; }
|
||||
h2 { font-size: 1.1em; color: var(--text-2); margin: 0 0 8px; }
|
||||
p { margin: 0; }
|
||||
|
||||
/* Navigation bar — shared across all pages */
|
||||
@@ -29,9 +91,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: #fff;
|
||||
background: var(--nav-bg);
|
||||
padding: 6px 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||
box-shadow: 0 2px 4px var(--shadow-nav);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -42,25 +104,25 @@
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
color: var(--link);
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
color: var(--text-2);
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
||||
.nav-user:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.nav-username {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -81,7 +143,7 @@
|
||||
.nav-initials {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #0066cc;
|
||||
background: var(--link);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -106,7 +168,7 @@
|
||||
.nav-hamburger span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: #555;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -118,13 +180,22 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--border-2);
|
||||
order: 3;
|
||||
}
|
||||
.nav-links.nav-open { display: flex; }
|
||||
.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;
|
||||
@@ -279,6 +350,17 @@
|
||||
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() {
|
||||
/* Start the shared tick loop */
|
||||
clockTick();
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
/* Message styling */
|
||||
#messages {
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
line-height: 1.0;
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
padding: 3px 7px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@@ -288,6 +288,31 @@
|
||||
}
|
||||
#ntable a.host-link { color: inherit; text-decoration: none; }
|
||||
#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>
|
||||
<script type="text/javascript">
|
||||
var cnt = 0;
|
||||
@@ -540,7 +565,7 @@
|
||||
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||
html += '</div>';
|
||||
msgs.insertAdjacentHTML("afterbegin", html);
|
||||
msgs.insertAdjacentHTML(state.history ? "beforeend" : "afterbegin", html);
|
||||
applyLogFilters();
|
||||
}
|
||||
cnt++;
|
||||
@@ -640,6 +665,7 @@
|
||||
<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>
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
.plugin-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
color: #444;
|
||||
min-width: 140px;
|
||||
}
|
||||
@@ -238,7 +238,7 @@
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
border-radius: 4px;
|
||||
@@ -261,7 +261,7 @@
|
||||
.data-table th.center { text-align: center; }
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 10px;
|
||||
/* padding: 6px 10px; */
|
||||
border-top: 1px solid #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
@@ -369,7 +369,7 @@
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: #aaa;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -379,7 +379,7 @@
|
||||
margin: 8px 0;
|
||||
border-radius: 3px;
|
||||
color: #c62828;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────────── */
|
||||
@@ -394,7 +394,7 @@
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
}
|
||||
.info-meta {
|
||||
display: grid;
|
||||
@@ -411,7 +411,48 @@
|
||||
}
|
||||
.info-note { color: #888; 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>
|
||||
|
||||
<body>
|
||||
@@ -873,7 +914,7 @@
|
||||
let html = '';
|
||||
switch (pluginName) {
|
||||
case 'os_info': html = renderOsInfoTable(cached.data); break;
|
||||
case 'cpu_monitor': html = renderCpuTable(cached.data); break;
|
||||
case 'cpu_monitor': html = renderCpuTable(hostname, cached.data); break;
|
||||
case 'memory_monitor': html = renderMemoryTable(cached.data); break;
|
||||
case 'disk_monitor': html = renderDiskTables(cached.data); break;
|
||||
case 'network_monitor':html = renderNetworkTables(cached.data); break;
|
||||
@@ -885,6 +926,10 @@
|
||||
|
||||
html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`;
|
||||
body.innerHTML = html;
|
||||
|
||||
if (pluginName === 'cpu_monitor') {
|
||||
fetchCpuHistory(hostname).then(samples => renderCpuChart(hostname, samples)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-plugin renderers ────────────────────────────────────────────────
|
||||
@@ -907,7 +952,92 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderCpuTable(d) {
|
||||
async function fetchCpuHistory(hostname) {
|
||||
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/cpu_monitor?limit=100`);
|
||||
if (!r.ok) return [];
|
||||
const json = await r.json();
|
||||
return json.samples || [];
|
||||
}
|
||||
|
||||
function renderCpuChart(hostname, samples) {
|
||||
const el = document.getElementById(`cpu-chart-${hostname}`);
|
||||
if (!el || !samples.length) return;
|
||||
|
||||
const pts = samples
|
||||
.filter(s => s.data.cpu_percent != null)
|
||||
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
||||
if (pts.length < 2) { el.style.display = 'none'; return; }
|
||||
|
||||
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
||||
const cW = W - PAD.left - PAD.right;
|
||||
const cH = H - PAD.top - PAD.bottom;
|
||||
|
||||
const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
|
||||
const tRange = tMax - tMin || 1;
|
||||
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
||||
|
||||
// Auto-scale Y axis with 10% padding, clamped to [0, 100]
|
||||
const vMin = Math.min(...pts.map(p => p.v));
|
||||
const vMax = Math.max(...pts.map(p => p.v));
|
||||
const vRange = vMax - vMin || 1;
|
||||
const vPad = Math.max(vRange * 0.1, 1);
|
||||
const yLow = Math.max(0, vMin - vPad);
|
||||
const yHigh = Math.min(100, vMax + vPad);
|
||||
const yRange = yHigh - yLow || 1;
|
||||
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
|
||||
|
||||
// Build polyline points and filled area path
|
||||
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
|
||||
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
|
||||
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
|
||||
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
|
||||
|
||||
// Color based on latest absolute CPU %
|
||||
const latest = pts[pts.length - 1].v;
|
||||
const strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
|
||||
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
|
||||
|
||||
// Compute nice tick step for ~3-5 grid lines
|
||||
const rawStep = yRange / 4;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
|
||||
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10;
|
||||
const tickStart = Math.ceil(yLow / niceStep) * niceStep;
|
||||
let gridLines = '';
|
||||
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
||||
const yy = y(v).toFixed(1);
|
||||
const label = Number.isInteger(v) ? v : v.toFixed(1);
|
||||
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
|
||||
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`;
|
||||
}
|
||||
|
||||
// X-axis time labels
|
||||
const fmt = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
const xLabels = `
|
||||
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text>
|
||||
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`;
|
||||
|
||||
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
|
||||
style="width:100%;height:${H}px;display:block;">
|
||||
<defs>
|
||||
<clipPath id="cpu-clip-${hostname}">
|
||||
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
${gridLines}
|
||||
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
||||
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
||||
<g clip-path="url(#cpu-clip-${hostname})">
|
||||
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
|
||||
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</g>
|
||||
${xLabels}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderCpuTable(hostname, d) {
|
||||
const KEYS = [
|
||||
['cpu_percent', 'CPU Usage', 'bar'],
|
||||
['load_1min', 'Load (1 min)', 'num'],
|
||||
@@ -925,7 +1055,8 @@
|
||||
];
|
||||
|
||||
const handled = new Set(KEYS.map(r => r[0]));
|
||||
let html = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
|
||||
let html = `<div id="cpu-chart-${hostname}" style="margin-bottom:8px;"></div>`;
|
||||
html += '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
|
||||
for (const [k, label, fmt] of KEYS) {
|
||||
if (!(k in d)) continue;
|
||||
const v = d[k];
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
border-radius: 4px;
|
||||
background: #f44336;
|
||||
color: #fff;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
@@ -157,7 +157,7 @@
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
font-weight: 500;
|
||||
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: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);
|
||||
@@ -477,6 +527,19 @@
|
||||
</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 -->
|
||||
<div class="section">
|
||||
<h2>Host Access</h2>
|
||||
@@ -523,6 +586,28 @@
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ---- Theme ----
|
||||
function applyTheme(pref) {
|
||||
var dark = pref === 'dark' ||
|
||||
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
||||
else { document.documentElement.removeAttribute('data-theme'); }
|
||||
}
|
||||
function setTheme(pref) {
|
||||
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
|
||||
applyTheme(pref);
|
||||
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||
});
|
||||
}
|
||||
(function() {
|
||||
var pref = 'auto';
|
||||
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
|
||||
document.querySelectorAll('.theme-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.themeVal === pref);
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- Identity ----
|
||||
async function saveIdentity() {
|
||||
const full_name = document.getElementById('profile-fullname').value;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
@@ -199,7 +199,7 @@
|
||||
.channel-field {
|
||||
display: flex;
|
||||
padding: 5px 14px;
|
||||
font-size: 0.85em;
|
||||
font-size: 1.00em;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -350,7 +350,7 @@
|
||||
.yaml-editor:focus { border-color: #0066cc; outline: none; }
|
||||
|
||||
/* ---- 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:hover { background: #0055aa; }
|
||||
.btn-success { background: #2a7a2a; color: #fff; }
|
||||
@@ -440,7 +440,7 @@
|
||||
}
|
||||
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||||
.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;
|
||||
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||||
}
|
||||
@@ -456,6 +456,67 @@
|
||||
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>
|
||||
|
||||
<body>
|
||||
@@ -742,6 +803,7 @@
|
||||
<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>
|
||||
@@ -766,6 +828,9 @@
|
||||
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"
|
||||
@@ -816,6 +881,11 @@
|
||||
<input type="number" class="field-input"
|
||||
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
|
||||
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 %}
|
||||
<input type="text" class="field-input"
|
||||
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||||
@@ -1019,6 +1089,8 @@
|
||||
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
|
||||
const v = parseInt(el.value, 10);
|
||||
_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 {
|
||||
_staged[apiSection][key] = el.value;
|
||||
}
|
||||
@@ -1467,6 +1539,7 @@
|
||||
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 };
|
||||
@@ -1474,6 +1547,7 @@
|
||||
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;
|
||||
});
|
||||
@@ -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-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>`;
|
||||
|
||||
@@ -232,6 +232,23 @@ def _make_timer_callbacks(uname, host, ctx):
|
||||
return on_overdue, on_unknown
|
||||
|
||||
|
||||
def _make_plugin_stale_callback(uname, ctx):
|
||||
"""Return an async callback that clears stale plugin data and its alerts."""
|
||||
msg_to_websockets = ctx.get("msg_to_websockets")
|
||||
|
||||
async def on_plugin_stale(host, plugin_name):
|
||||
host.plugin_data.pop(plugin_name, None)
|
||||
stale_keys = [k for k in host.alert_states if k.startswith(f"{plugin_name}.")]
|
||||
for k in stale_keys:
|
||||
del host.alert_states[k]
|
||||
eventlog(uname, "INFO", f"plugin data stale: {plugin_name}")
|
||||
if msg_to_websockets:
|
||||
msg_to_websockets("plugin_stale", {"host": uname, "plugin": plugin_name})
|
||||
msg_to_websockets("host", host.stateinfo())
|
||||
|
||||
return on_plugin_stale
|
||||
|
||||
|
||||
def restore_connection_timers(hbdclass, ctx):
|
||||
"""Restore overdue timers for all loaded connections after a pickle restore.
|
||||
|
||||
@@ -333,6 +350,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
# Use new config function to check dyndns
|
||||
dyndnshosts = config_mod.get_dyndnshosts(cfg)
|
||||
host.dyn = uname in dyndnshosts
|
||||
watchhosts = config_mod.get_watchhosts(cfg)
|
||||
host.watched = uname in watchhosts
|
||||
# Apply user-access settings from config
|
||||
access = config_mod.get_host_access(cfg, uname)
|
||||
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||
@@ -370,6 +389,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if k not in ("ID", "plugin", "id", "name")}
|
||||
# Store plugin data with timestamp
|
||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||
# Reset stale timer — 3× the heartbeat interval (min 60 s)
|
||||
stale_timeout = max(host.interval * 3, 60)
|
||||
host.reset_plugin_timer(plugin_name, stale_timeout,
|
||||
_make_plugin_stale_callback(uname, ctx))
|
||||
|
||||
# If os_info reports an owner and none is configured server-side, apply it
|
||||
if plugin_name == "os_info":
|
||||
|
||||
+5
-3
@@ -85,13 +85,15 @@ async def handler(request):
|
||||
except Exception as 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:
|
||||
try:
|
||||
for m in data.msgs:
|
||||
for m in reversed(data.msgs):
|
||||
host_name = m.get("host") if isinstance(m, dict) else None
|
||||
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:
|
||||
logger.error("Error sending initial messages: %s", e)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hbd"
|
||||
version = "5.3.6"
|
||||
version = "5.3.10"
|
||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+16
-1
@@ -5,9 +5,23 @@ uv version --bump patch
|
||||
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/" 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
|
||||
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
|
||||
# tag version
|
||||
git tag -a v$VER -m "Version $VER"
|
||||
@@ -15,3 +29,4 @@ git push --tags
|
||||
|
||||
rm hbd/__init__.py.bak
|
||||
rm scripts/hbc_mini.py.bak
|
||||
rm README.md.bak
|
||||
@@ -789,7 +789,7 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
|
||||
* Plugin: memory_monitor
|
||||
* Linux: /proc/meminfo
|
||||
* FreeBSD: sysctl vm.stats.vm.*
|
||||
* NetBSD: sysctl vm.uvmexp (struct uvmexp)
|
||||
* NetBSD: sysctl vm.uvmexp (struct uvmexp_sysctl)
|
||||
* ============================================================ */
|
||||
|
||||
/* emit the common kvdict fields and send */
|
||||
@@ -896,9 +896,9 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
||||
|
||||
static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
||||
(void)cfg;
|
||||
struct uvmexp uvm;
|
||||
struct uvmexp_sysctl uvm;
|
||||
size_t len = sizeof(uvm);
|
||||
int mib[2] = {CTL_VM, VM_UVMEXP};
|
||||
int mib[2] = {CTL_VM, VM_UVMEXP2};
|
||||
if (sysctl(mib, 2, &uvm, &len, NULL, 0) != 0) return;
|
||||
|
||||
long long ps = uvm.pagesize;
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# updated by scripts/bumpminor.sh
|
||||
__version__ = "5.3.6"
|
||||
__version__ = "5.3.10"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol (mirrors hbd/common/proto.py)
|
||||
|
||||
Reference in New Issue
Block a user