Compare commits
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here, organized by release.
|
All notable changes to this project are documented here, organized by release.
|
||||||
|
|
||||||
|
## [5.3.12]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- retire per-channel private flag on profile page
|
||||||
|
- settings UI renders per-user edit rights and owner controls
|
||||||
|
- settings page accessible to all authenticated users
|
||||||
|
- per-user filtering of settings sections
|
||||||
|
- scoped non-admin saves through POST /api/0/config
|
||||||
|
- threshold form payload carries per-config owner
|
||||||
|
- channel ownership rule — owner-presence means private, admin promote/demote
|
||||||
|
- scoped threshold-config merge for non-admin saves
|
||||||
|
- scoped host merge for non-admin config saves
|
||||||
|
- ownership visibility helpers for config entities
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- users loop clobbered requesting username in settings filtering
|
||||||
|
- type annotations for mypy parity with master
|
||||||
|
- don't re-announce boot/shutdown on SIGHUP restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [5.3.11]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- add Windows hbc client with PyInstaller spec and NSSM install script
|
||||||
|
- clear alerts for individual plugin metrics that disappear between samples
|
||||||
|
- show alerts for all hosts on Alerts page, not just watched
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- declare plugin interval so stale data waits two full intervals
|
||||||
|
- cap event buffer and replay only recent messages on dashboard connect
|
||||||
|
- strip plugin timers when pickling Host to prevent save failure
|
||||||
|
- correct zero-safe pathconf checks and connectivity prefix match
|
||||||
|
- address security vulnerabilities from audit
|
||||||
|
- don't purge connectivity/rtt alerts in purge_stale_alerts
|
||||||
|
- restore connectivity alerts for overdue/unknown/down hosts on startup
|
||||||
|
- clear plugin data and timers on connection UP transition
|
||||||
|
- restore host link from Dashboard to Host Overview
|
||||||
|
- don't set stale timer until two plugin samples establish real interval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [5.3.10]
|
## [5.3.10]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
|
|||||||
└────────────────────┘ └────────────────────────────┘
|
└────────────────────┘ └────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Package:** `hbd` v5.3.10
|
**Package:** `hbd` v5.3.12
|
||||||
**Python:** 3.11+
|
**Python:** 3.11+
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
|
|||||||
+15
-13
@@ -32,15 +32,16 @@ base_url: https://hbd.example.com
|
|||||||
|
|
||||||
### Channel definitions
|
### Channel definitions
|
||||||
|
|
||||||
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
|
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Ownership is the single visibility signal:
|
||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
|
| `owner` | *(absent)* | Owning username. Present = private to that user; absent = global. |
|
||||||
| `private` | `false` | When `true`, only the owner can see and select this channel. |
|
|
||||||
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
|
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
|
||||||
|
|
||||||
**Admin-created channels** (set in the config file or via the admin settings UI) are public by default — all users can select them:
|
(The former `private` flag is retired; leftover `private` keys are ignored and dropped on the next edit.)
|
||||||
|
|
||||||
|
**Global channels** (no `owner`; set in the config file or by an admin) can be selected by all users but edited only by admins:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -90,7 +91,7 @@ notification_channels:
|
|||||||
username: heartbeat-bot
|
username: heartbeat-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
**User-created channels** are written by authenticated users through the API or their profile page. They carry an `owner` field and optionally `private: true`:
|
**User-created channels** are written by authenticated users through the API, their profile page, or the settings page. They carry an `owner` field and are private to that user:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
notification_channels:
|
notification_channels:
|
||||||
@@ -99,17 +100,18 @@ notification_channels:
|
|||||||
type: pushover
|
type: pushover
|
||||||
token: personal-token
|
token: personal-token
|
||||||
user: personal-key
|
user: personal-key
|
||||||
owner: alice # created by alice
|
owner: alice # private to alice
|
||||||
private: true # only alice can see this channel
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Channel visibility
|
### Channel visibility
|
||||||
|
|
||||||
| Channel | Who can see / select it |
|
| Channel | Who can see / select it | Who can edit it |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| No `private` field (or `private: false`) | All users |
|
| No `owner` (global) | All users | Admins |
|
||||||
| `private: true` | Only the `owner` |
|
| `owner` set (private) | Only the `owner` | The owner |
|
||||||
| Any channel | Admins always see everything |
|
| Any channel | Admins always see everything | Admins |
|
||||||
|
|
||||||
|
Admins can **promote** a private channel to global by clearing its owner on the settings page, or **demote** a global channel by assigning an owner.
|
||||||
|
|
||||||
### Users with notification channels
|
### Users with notification channels
|
||||||
|
|
||||||
@@ -299,7 +301,7 @@ Called once at startup from `main.py`. Pass the running asyncio event loop so Ma
|
|||||||
- Check that the host has an `owner` or `managers` set
|
- Check that the host has an `owner` or `managers` set
|
||||||
- Check that users have `notification_channels` listed
|
- Check that users have `notification_channels` listed
|
||||||
- Check that the channel names in user config match keys under `notification_channels:`
|
- Check that the channel names in user config match keys under `notification_channels:`
|
||||||
- If a user can't select a channel, check whether it is `private: true` and owned by someone else
|
- If a user can't select a channel, check whether it has an `owner` other than that user
|
||||||
|
|
||||||
**min_level filtering too aggressive:**
|
**min_level filtering too aggressive:**
|
||||||
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
- Default is `WARNING` — both WARNING and CRITICAL are sent
|
||||||
|
|||||||
+10
-1
@@ -19,6 +19,15 @@ Users are defined in the server config file. Each host can have an **owner**, ze
|
|||||||
|
|
||||||
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
|
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
|
||||||
|
|
||||||
|
### Settings page access
|
||||||
|
|
||||||
|
All authenticated users may open `/settings`. Admins see every section; non-admins see only **Notification Channels**, **Hosts**, and **Threshold Configurations**, filtered to global items plus what they own or manage:
|
||||||
|
|
||||||
|
- **Owners** may add hosts, delete their hosts, edit host settings, and change access lists (managers/monitors, ownership transfer).
|
||||||
|
- **Managers** may edit host settings (watch, dyndns, channel and threshold assignments) but not access lists, and may not delete hosts.
|
||||||
|
- Anyone may create private notification channels and threshold configs (owned by them) and assign them — or global ones — to their hosts.
|
||||||
|
- Admins promote a private channel/threshold config to global by clearing its owner, or demote by assigning one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -200,7 +209,7 @@ Update the current user's profile. All fields are optional — send only what yo
|
|||||||
```json
|
```json
|
||||||
{ "notification_channels": ["pushover_ops", "email_ops"] }
|
{ "notification_channels": ["pushover_ops", "email_ops"] }
|
||||||
```
|
```
|
||||||
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
|
Only channels visible to the user (global + own) are accepted; others are silently dropped.
|
||||||
|
|
||||||
**Change password:**
|
**Change password:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.3.10"
|
__version__ = "5.3.12"
|
||||||
|
|||||||
+11
-7
@@ -356,7 +356,8 @@ async def _info_plugin_refresh_loop(conn: AsyncConnection, info_plugins: List):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto(
|
||||||
|
{"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
logger.info(f"Resent {plugin.name} data")
|
logger.info(f"Resent {plugin.name} data")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
||||||
@@ -377,8 +378,8 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
# Create PLG message with plugin name
|
# Create PLG message with plugin name and declared interval
|
||||||
plugin_msg = {"plugin": plugin.name, **data}
|
plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
|
||||||
await conn.sendto(plugin_msg, "PLG")
|
await conn.sendto(plugin_msg, "PLG")
|
||||||
logger.info(f"Sent {plugin.name} data")
|
logger.info(f"Sent {plugin.name} data")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -430,7 +431,7 @@ async def plugin_collector_interval(
|
|||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
# Don't use encode_plugin_data - create dict directly
|
# Don't use encode_plugin_data - create dict directly
|
||||||
plugin_msg = {"plugin": plugin.name, **data}
|
plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
|
||||||
await conn.sendto(plugin_msg, "PLG")
|
await conn.sendto(plugin_msg, "PLG")
|
||||||
logger.debug(f"Sent {plugin.name} data")
|
logger.debug(f"Sent {plugin.name} data")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -485,7 +486,8 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
logger.info("Cleaning up connections")
|
logger.info("Cleaning up connections")
|
||||||
|
|
||||||
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
||||||
if target and send_shutdown:
|
# A SIGHUP restart is not a host shutdown, so don't announce one.
|
||||||
|
if target and send_shutdown and not dorestart:
|
||||||
try:
|
try:
|
||||||
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -563,7 +565,6 @@ async def async_main(args, config):
|
|||||||
boot_msg = {}
|
boot_msg = {}
|
||||||
if args.boot:
|
if args.boot:
|
||||||
boot_msg["boot"] = 1
|
boot_msg["boot"] = 1
|
||||||
args.boot = False # Clear boot flag so we don't send it again in main loop
|
|
||||||
send_shutdown = True
|
send_shutdown = True
|
||||||
if args.message:
|
if args.message:
|
||||||
boot_msg["service"] = "service"
|
boot_msg["service"] = "service"
|
||||||
@@ -792,7 +793,10 @@ def main(argv=None):
|
|||||||
# Handle restart
|
# Handle restart
|
||||||
if dorestart:
|
if dorestart:
|
||||||
logging.info("Restarting...")
|
logging.info("Restarting...")
|
||||||
os.execv(sys.argv[0], sys.argv)
|
# Drop -b/--boot so the re-exec'd process doesn't re-announce a boot;
|
||||||
|
# a SIGHUP restart is not a host reboot.
|
||||||
|
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
|
||||||
|
os.execv(restart_argv[0], restart_argv)
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
try:
|
try:
|
||||||
# Maximum filename length
|
# Maximum filename length
|
||||||
max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX')
|
max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX')
|
||||||
if max_name:
|
if max_name is not None:
|
||||||
fs_info['maxfile'] = max_name
|
fs_info['maxfile'] = max_name
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -135,7 +135,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
try:
|
try:
|
||||||
# Maximum path length
|
# Maximum path length
|
||||||
max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX')
|
max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX')
|
||||||
if max_path:
|
if max_path is not None:
|
||||||
fs_info['maxpath'] = max_path
|
fs_info['maxpath'] = max_path
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Ownership rules for config-file entities (hosts, channels, threshold configs).
|
||||||
|
|
||||||
|
Rule: an entry whose config dict has a non-empty ``owner`` is private to that
|
||||||
|
owner — visible, usable, and editable only by the owner (and admins). An
|
||||||
|
entry with no ``owner`` is global: usable by everyone, editable by admins only.
|
||||||
|
|
||||||
|
The scoped-merge helpers implement the non-admin save path for
|
||||||
|
``POST /api/0/config``: the caller's visible subset of a section is replaced
|
||||||
|
by the submitted payload; everything else is preserved untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedMergeError(Exception):
|
||||||
|
"""A non-admin payload violated an ownership rule; message names the entry."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_global(cfg: Any) -> bool:
|
||||||
|
"""True when *cfg* has no owner (usable by everyone)."""
|
||||||
|
return not (isinstance(cfg, dict) and cfg.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_use(cfg: Any, username: str) -> bool:
|
||||||
|
"""True when *username* may use/see this entry (global or own)."""
|
||||||
|
return is_global(cfg) or cfg.get("owner") == username
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def user_hosts(hosts_cfg: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of *hosts_cfg* where *username* is owner or manager."""
|
||||||
|
result = {}
|
||||||
|
for name, cfg in (hosts_cfg or {}).items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
if cfg.get("owner") == username or username in _as_list(cfg.get("managers")):
|
||||||
|
result[name] = cfg
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def user_channels(channels_cfg: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of channels usable by *username*: global + own."""
|
||||||
|
return {
|
||||||
|
name: cfg for name, cfg in (channels_cfg or {}).items()
|
||||||
|
if isinstance(cfg, dict) and user_can_use(cfg, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_threshold_configs(threshold_cfgs: Any, username: str) -> Dict[str, Any]:
|
||||||
|
"""Subset of threshold configs usable by *username*: global + own."""
|
||||||
|
return {
|
||||||
|
name: cfg for name, cfg in (threshold_cfgs or {}).items()
|
||||||
|
if isinstance(cfg, dict) and user_can_use(cfg, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_HOST_SETTING_KEYS = ("watch", "dyndns", "notification_channels", "threshold_config")
|
||||||
|
_HOST_ACCESS_KEYS = ("owner", "managers", "monitors")
|
||||||
|
_HOST_ALLOWED_KEYS = frozenset(_HOST_SETTING_KEYS) | frozenset(_HOST_ACCESS_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_added_assignments(host_name: str, old_cfg: Any, new_cfg: dict,
|
||||||
|
key: str, usable: set) -> None:
|
||||||
|
"""Every value under *key* not already on the host must be in *usable*."""
|
||||||
|
old_vals = set(_as_list((old_cfg or {}).get(key)))
|
||||||
|
for v in _as_list(new_cfg.get(key)):
|
||||||
|
if v not in old_vals and v not in usable:
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {host_name!r}: {key} entry {v!r} is not available to you")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_hosts_scoped(existing: Any, payload: Any, username: str,
|
||||||
|
channels_cfg: Any, threshold_cfgs: Any) -> Dict[str, Any]:
|
||||||
|
"""Return a new hosts section with *username*'s visible subset replaced by *payload*.
|
||||||
|
|
||||||
|
Hosts the user cannot see are preserved untouched. Visible hosts missing
|
||||||
|
from the payload are deleted (owners only). Managers may change setting
|
||||||
|
keys but not access keys and may not delete. New hosts get their owner
|
||||||
|
forced to *username*. Newly added channel/threshold assignments must be
|
||||||
|
global or owned by the user. Violations raise ScopedMergeError.
|
||||||
|
"""
|
||||||
|
existing = existing or {}
|
||||||
|
payload = payload or {}
|
||||||
|
visible = user_hosts(existing, username)
|
||||||
|
usable_channels = set(user_channels(channels_cfg, username))
|
||||||
|
usable_tcs = set(user_threshold_configs(threshold_cfgs, username)) | {"default"}
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {n: c for n, c in existing.items() if n not in visible}
|
||||||
|
|
||||||
|
for name, entry in payload.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ScopedMergeError(f"host {name!r}: invalid entry")
|
||||||
|
unknown = set(entry) - _HOST_ALLOWED_KEYS
|
||||||
|
if unknown:
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: fields not permitted: {', '.join(sorted(unknown))}")
|
||||||
|
if name in existing and name not in visible:
|
||||||
|
raise ScopedMergeError(f"host {name!r}: you are not an owner or manager")
|
||||||
|
|
||||||
|
old = visible.get(name)
|
||||||
|
if old is None:
|
||||||
|
new_cfg = dict(entry)
|
||||||
|
new_cfg["owner"] = username
|
||||||
|
else:
|
||||||
|
new_cfg = dict(old)
|
||||||
|
for key in _HOST_SETTING_KEYS:
|
||||||
|
if key in entry:
|
||||||
|
new_cfg[key] = entry[key]
|
||||||
|
else:
|
||||||
|
new_cfg.pop(key, None)
|
||||||
|
if old.get("owner") == username:
|
||||||
|
for key in _HOST_ACCESS_KEYS:
|
||||||
|
if key in entry:
|
||||||
|
new_cfg[key] = entry[key]
|
||||||
|
else:
|
||||||
|
new_cfg.pop(key, None)
|
||||||
|
else:
|
||||||
|
if "owner" in entry and (entry.get("owner") or None) != (old.get("owner") or None):
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: only the owner may change ownership")
|
||||||
|
for key in ("managers", "monitors"):
|
||||||
|
if key in entry and set(_as_list(entry[key])) != set(_as_list(old.get(key))):
|
||||||
|
raise ScopedMergeError(
|
||||||
|
f"host {name!r}: only the owner may change {key}")
|
||||||
|
|
||||||
|
_check_added_assignments(name, old, new_cfg, "notification_channels", usable_channels)
|
||||||
|
_check_added_assignments(name, old, new_cfg, "threshold_config", usable_tcs)
|
||||||
|
result[name] = new_cfg
|
||||||
|
|
||||||
|
for name, old in visible.items():
|
||||||
|
if name not in payload and old.get("owner") != username:
|
||||||
|
raise ScopedMergeError(f"host {name!r}: only the owner may delete a host")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_threshold_configs_scoped(existing: Any, payload: Any,
|
||||||
|
username: str) -> Dict[str, Any]:
|
||||||
|
"""Return a new threshold_configs section with the user's own configs
|
||||||
|
replaced by *payload*.
|
||||||
|
|
||||||
|
Global and foreign-owned configs are preserved and may not appear in the
|
||||||
|
payload ('default' included). Own configs missing from the payload are
|
||||||
|
deleted. Every payload entry gets its owner forced to *username*.
|
||||||
|
"""
|
||||||
|
existing = existing or {}
|
||||||
|
payload = payload or {}
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
n: c for n, c in existing.items()
|
||||||
|
if not (isinstance(c, dict) and c.get("owner") == username)
|
||||||
|
}
|
||||||
|
for name, entry in payload.items():
|
||||||
|
if name == "default":
|
||||||
|
raise ScopedMergeError("threshold config 'default' is global and admin-managed")
|
||||||
|
old = existing.get(name)
|
||||||
|
if old is not None and (not isinstance(old, dict) or old.get("owner") != username):
|
||||||
|
raise ScopedMergeError(f"threshold config {name!r}: not owned by you")
|
||||||
|
new_cfg = dict(entry) if isinstance(entry, dict) else {}
|
||||||
|
new_cfg["owner"] = username
|
||||||
|
result[name] = new_cfg
|
||||||
|
return result
|
||||||
@@ -304,6 +304,14 @@ class Host:
|
|||||||
self.managers: list = [] # usernames with manager role
|
self.managers: list = [] # usernames with manager role
|
||||||
self.monitors: list = [] # usernames with monitor role
|
self.monitors: list = [] # usernames with monitor role
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
"""Prepare Host for pickling by excluding non-serializable timer objects."""
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
# asyncio TimerHandles (and their lambda callbacks) can't be pickled.
|
||||||
|
# They're recreated when the next PLG arrives after unpickling.
|
||||||
|
state['plugin_timers'] = {}
|
||||||
|
return state
|
||||||
|
|
||||||
def statedict(self):
|
def statedict(self):
|
||||||
d = {}
|
d = {}
|
||||||
d["raw_name"] = self.name
|
d["raw_name"] = self.name
|
||||||
@@ -367,7 +375,7 @@ class Host:
|
|||||||
def stateinfo(self):
|
def stateinfo(self):
|
||||||
ddict = {}
|
ddict = {}
|
||||||
for d in self.__dict__:
|
for d in self.__dict__:
|
||||||
if d in ["alert_states", "plugin_data"]:
|
if d in ["alert_states", "plugin_data", "plugin_timers"]:
|
||||||
continue
|
continue
|
||||||
if d == "connections":
|
if d == "connections":
|
||||||
cl = []
|
cl = []
|
||||||
|
|||||||
+89
-41
@@ -20,6 +20,7 @@ from . import users as users_mod
|
|||||||
from . import oauth as oauth_mod
|
from . import oauth as oauth_mod
|
||||||
from . import ws as ws_mod
|
from . import ws as ws_mod
|
||||||
from . import configio as configio_mod
|
from . import configio as configio_mod
|
||||||
|
from . import config_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,19 +28,25 @@ eventlog = notify_mod.eventlog
|
|||||||
|
|
||||||
|
|
||||||
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
def _build_threshold_configs_from_form(form_data: dict) -> dict:
|
||||||
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
|
"""Convert form-submitted threshold data to the nested threshold_configs structure.
|
||||||
|
|
||||||
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
|
Input: {config_name: {owner?: str, metrics: {metric_path: {warning, critical, ...}}}}
|
||||||
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
|
Output: {config_name: {owner?: str, thresholds: {plugin: {metric: {...}}}}}
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
for config_name, metrics in form_data.items():
|
for config_name, cfg in form_data.items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
metrics = cfg.get("metrics")
|
||||||
if not isinstance(metrics, dict):
|
if not isinstance(metrics, dict):
|
||||||
continue
|
continue
|
||||||
thresholds = {}
|
thresholds: dict = {}
|
||||||
for metric_path, values in metrics.items():
|
for metric_path, values in metrics.items():
|
||||||
_insert_threshold_metric(thresholds, metric_path, values)
|
_insert_threshold_metric(thresholds, metric_path, values)
|
||||||
result[config_name] = {"thresholds": thresholds}
|
entry = {"thresholds": thresholds}
|
||||||
|
if cfg.get("owner"):
|
||||||
|
entry["owner"] = cfg["owner"]
|
||||||
|
result[config_name] = entry
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -424,7 +431,7 @@ async def start(
|
|||||||
# Resolve templates directory relative to the hbd package
|
# Resolve templates directory relative to the hbd package
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
host = config.get("hb_host", "localhost")
|
host = config.get("hb_host", "localhost")
|
||||||
extra_scripts = config.get("http_extra_scripts", "")
|
extra_scripts = config.get("http_extra_scripts", "")
|
||||||
host = request.host # includes port if non-standard
|
host = request.host # includes port if non-standard
|
||||||
@@ -597,8 +604,6 @@ 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:
|
||||||
@@ -692,7 +697,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
# Collect all hosts with plugin data (filtered by visibility)
|
# Collect all hosts with plugin data (filtered by visibility)
|
||||||
hosts_with_plugins = []
|
hosts_with_plugins = []
|
||||||
@@ -723,7 +728,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
tmpl = env.get_template("alerts.html")
|
tmpl = env.get_template("alerts.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
@@ -780,6 +785,8 @@ async def start(
|
|||||||
token = users_mod.create_session(username)
|
token = users_mod.create_session(username)
|
||||||
eventlog("hbd", "INFO", f"Login: {username} via password")
|
eventlog("hbd", "INFO", f"Login: {username} via password")
|
||||||
redirect_to = request.rel_url.query.get("next", "/")
|
redirect_to = request.rel_url.query.get("next", "/")
|
||||||
|
if not redirect_to.startswith("/"):
|
||||||
|
redirect_to = "/"
|
||||||
resp = web.HTTPFound(redirect_to)
|
resp = web.HTTPFound(redirect_to)
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -891,6 +898,13 @@ async def start(
|
|||||||
if not target_user.avatar_is_local():
|
if not target_user.avatar_is_local():
|
||||||
return web.Response(status=404, text="No local avatar configured")
|
return web.Response(status=404, text="No local avatar configured")
|
||||||
path = target_user.avatar
|
path = target_user.avatar
|
||||||
|
avatar_dir = config.get("avatar_dir") or (
|
||||||
|
os.path.dirname(os.path.realpath(_config_path)) if _config_path else None
|
||||||
|
)
|
||||||
|
if not avatar_dir:
|
||||||
|
return web.Response(status=403, text="Local avatars not configured")
|
||||||
|
if not os.path.realpath(path).startswith(os.path.realpath(avatar_dir) + os.sep):
|
||||||
|
return web.Response(status=403, text="Forbidden")
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
return web.Response(status=404, text="Avatar file not found")
|
return web.Response(status=404, text="Avatar file not found")
|
||||||
# Infer content-type from extension
|
# Infer content-type from extension
|
||||||
@@ -994,7 +1008,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
|
|
||||||
# Build host access summary for this user.
|
# Build host access summary for this user.
|
||||||
# Merge live hosts with config-only hosts (not yet seen) so the profile
|
# Merge live hosts with config-only hosts (not yet seen) so the profile
|
||||||
@@ -1044,7 +1058,7 @@ async def start(
|
|||||||
"name": name,
|
"name": name,
|
||||||
"type": cfg.get("type", ""),
|
"type": cfg.get("type", ""),
|
||||||
"owner": cfg.get("owner"),
|
"owner": cfg.get("owner"),
|
||||||
"private": bool(cfg.get("private", False)),
|
"private": not config_access.is_global(cfg),
|
||||||
}
|
}
|
||||||
for name, cfg in visible_channels.items()
|
for name, cfg in visible_channels.items()
|
||||||
if isinstance(cfg, dict)
|
if isinstance(cfg, dict)
|
||||||
@@ -1078,7 +1092,7 @@ async def start(
|
|||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
from hbd import __version__ as hbd_version
|
from hbd import __version__ as hbd_version
|
||||||
|
|
||||||
uptime_secs = int(time.time() - _start_epoch)
|
uptime_secs = int(time.time() - _start_epoch)
|
||||||
@@ -1112,19 +1126,18 @@ async def start(
|
|||||||
return web.Response(text=body, content_type="text/html")
|
return web.Response(text=body, content_type="text/html")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Settings page (admin only)
|
# Settings page
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def settings_page(request):
|
async def settings_page(request):
|
||||||
"""GET /settings — read-only view of the current server configuration."""
|
"""GET /settings — server configuration; non-admins see only what they own or manage."""
|
||||||
current_user, _ = _require_auth_redirect(request)
|
current_user, _ = _require_auth_redirect(request)
|
||||||
if current_user and not current_user.admin:
|
|
||||||
raise web.HTTPForbidden(reason="Admin access required")
|
|
||||||
pkg_dir = os.path.dirname(__file__)
|
pkg_dir = os.path.dirname(__file__)
|
||||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
|
||||||
tmpl = env.get_template("settings.html")
|
tmpl = env.get_template("settings.html")
|
||||||
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
|
settings_data = settings_mod.get_settings_data(
|
||||||
|
config, threshold_checker=threshold_checker, user=current_user)
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
title="Settings - Heartbeat",
|
title="Settings - Heartbeat",
|
||||||
sections=settings_data["sections"],
|
sections=settings_data["sections"],
|
||||||
@@ -1274,12 +1287,16 @@ async def start(
|
|||||||
return web.json_response({"backups": backups})
|
return web.json_response({"backups": backups})
|
||||||
|
|
||||||
async def api_config_post(request):
|
async def api_config_post(request):
|
||||||
"""POST /api/0/config — publish staged changes to .hb.yaml. Admin only."""
|
"""POST /api/0/config — publish staged changes to .hb.yaml.
|
||||||
|
|
||||||
|
Admins may write any section. Non-admins may submit only 'hosts' and
|
||||||
|
'thresholds'; their payload is merged into the config scoped to the
|
||||||
|
entries they own or manage (see hbd.server.config_access).
|
||||||
|
"""
|
||||||
user, err = _require_auth(request)
|
user, err = _require_auth(request)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
if user and not user.admin:
|
is_admin = user is None or user.admin
|
||||||
return web.json_response({"error": "Forbidden"}, status=403)
|
|
||||||
if not _config_path:
|
if not _config_path:
|
||||||
return web.json_response({"error": "Config path not available"}, status=503)
|
return web.json_response({"error": "Config path not available"}, status=503)
|
||||||
try:
|
try:
|
||||||
@@ -1290,6 +1307,13 @@ async def start(
|
|||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
extra = set(payload) - {"hosts", "thresholds"}
|
||||||
|
if extra:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Not permitted to edit: {', '.join(sorted(extra))}"},
|
||||||
|
status=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = configio_mod.read_roundtrip(_config_path)
|
data = configio_mod.read_roundtrip(_config_path)
|
||||||
|
|
||||||
@@ -1338,18 +1362,36 @@ async def start(
|
|||||||
if "thresholds" in payload:
|
if "thresholds" in payload:
|
||||||
tc = payload["thresholds"]
|
tc = payload["thresholds"]
|
||||||
if isinstance(tc, str):
|
if isinstance(tc, str):
|
||||||
|
if not is_admin:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
configio_mod.apply_yaml_section(data, "thresholds", tc)
|
||||||
elif isinstance(tc, dict):
|
elif isinstance(tc, dict):
|
||||||
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
|
built = _build_threshold_configs_from_form(tc)
|
||||||
|
if is_admin:
|
||||||
|
data["threshold_configs"] = built
|
||||||
|
else:
|
||||||
|
data["threshold_configs"] = config_access.merge_threshold_configs_scoped(
|
||||||
|
data.get("threshold_configs") or {}, built, user.username)
|
||||||
|
|
||||||
if "hosts" in payload:
|
if "hosts" in payload:
|
||||||
h = payload["hosts"]
|
h = payload["hosts"]
|
||||||
if isinstance(h, dict):
|
if isinstance(h, dict):
|
||||||
|
if is_admin:
|
||||||
configio_mod.apply_structured_section(data, "hosts", h)
|
configio_mod.apply_structured_section(data, "hosts", h)
|
||||||
else:
|
else:
|
||||||
|
merged = config_access.merge_hosts_scoped(
|
||||||
|
dict(data.get("hosts") or {}), h, user.username,
|
||||||
|
data.get("notification_channels") or {},
|
||||||
|
data.get("threshold_configs") or {})
|
||||||
|
configio_mod.apply_structured_section(data, "hosts", merged)
|
||||||
|
elif is_admin:
|
||||||
configio_mod.apply_yaml_section(data, "hosts", h)
|
configio_mod.apply_yaml_section(data, "hosts", h)
|
||||||
|
else:
|
||||||
|
return web.json_response({"error": "Forbidden"}, status=403)
|
||||||
|
|
||||||
configio_mod.write_config(_config_path, data)
|
configio_mod.write_config(_config_path, data)
|
||||||
|
except config_access.ScopedMergeError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=403)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Config write failed: %s", exc)
|
logger.error("Config write failed: %s", exc)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
@@ -1400,19 +1442,13 @@ async def start(
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def _visible_channels_for_user(user):
|
def _visible_channels_for_user(user):
|
||||||
"""Return {name: cfg} of channels visible to user (public + own private)."""
|
"""Return {name: cfg} of channels visible to user (global + own)."""
|
||||||
all_channels = config.get("notification_channels") or {}
|
all_channels = config.get("notification_channels") or {}
|
||||||
if user is None:
|
if user is None:
|
||||||
return {}
|
return {}
|
||||||
if user.admin:
|
if user.admin:
|
||||||
return dict(all_channels)
|
return dict(all_channels)
|
||||||
visible = {}
|
return config_access.user_channels(all_channels, user.username)
|
||||||
for name, cfg in all_channels.items():
|
|
||||||
if not isinstance(cfg, dict):
|
|
||||||
continue
|
|
||||||
if not cfg.get("private") or cfg.get("owner") == user.username:
|
|
||||||
visible[name] = cfg
|
|
||||||
return visible
|
|
||||||
|
|
||||||
def _build_channel_response(ch_name, ch_cfg):
|
def _build_channel_response(ch_name, ch_cfg):
|
||||||
"""Serialize a channel config dict for the API response."""
|
"""Serialize a channel config dict for the API response."""
|
||||||
@@ -1436,7 +1472,7 @@ async def start(
|
|||||||
"type": ch_type,
|
"type": ch_type,
|
||||||
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
"owner": ch_cfg.get("owner"),
|
"owner": ch_cfg.get("owner"),
|
||||||
"private": bool(ch_cfg.get("private", False)),
|
"private": not config_access.is_global(ch_cfg),
|
||||||
"min_level": ch_cfg.get("min_level", "WARNING"),
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
}
|
}
|
||||||
@@ -1501,9 +1537,12 @@ async def start(
|
|||||||
|
|
||||||
if body.get("min_level"):
|
if body.get("min_level"):
|
||||||
channel_cfg["min_level"] = body["min_level"]
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
|
if user.admin:
|
||||||
|
owner = (body.get("owner") or "").strip()
|
||||||
|
if owner:
|
||||||
|
channel_cfg["owner"] = owner
|
||||||
|
else:
|
||||||
channel_cfg["owner"] = user.username
|
channel_cfg["owner"] = user.username
|
||||||
if body.get("private"):
|
|
||||||
channel_cfg["private"] = True
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
disk_data = configio_mod.read_roundtrip(_config_path)
|
disk_data = configio_mod.read_roundtrip(_config_path)
|
||||||
@@ -1568,12 +1607,12 @@ async def start(
|
|||||||
|
|
||||||
if body.get("min_level"):
|
if body.get("min_level"):
|
||||||
channel_cfg["min_level"] = body["min_level"]
|
channel_cfg["min_level"] = body["min_level"]
|
||||||
if owner is not None:
|
if user.admin:
|
||||||
|
new_owner = (body.get("owner") or "").strip() if "owner" in body else (owner or "")
|
||||||
|
if new_owner:
|
||||||
|
channel_cfg["owner"] = new_owner
|
||||||
|
elif owner is not None:
|
||||||
channel_cfg["owner"] = owner
|
channel_cfg["owner"] = owner
|
||||||
if "private" in body:
|
|
||||||
channel_cfg["private"] = bool(body["private"])
|
|
||||||
elif existing_on_disk.get("private"):
|
|
||||||
channel_cfg["private"] = True
|
|
||||||
|
|
||||||
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
|
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
|
||||||
configio_mod.write_config(_config_path, disk_data)
|
configio_mod.write_config(_config_path, disk_data)
|
||||||
@@ -1661,7 +1700,16 @@ async def start(
|
|||||||
if "full_name" in body:
|
if "full_name" in body:
|
||||||
user_entry["full_name"] = str(body["full_name"])
|
user_entry["full_name"] = str(body["full_name"])
|
||||||
if "avatar" in body:
|
if "avatar" in body:
|
||||||
user_entry["avatar"] = str(body["avatar"])
|
avatar_val = str(body["avatar"])
|
||||||
|
if avatar_val.startswith("/"):
|
||||||
|
avatar_dir = config.get("avatar_dir") or (
|
||||||
|
os.path.dirname(os.path.realpath(_config_path)) if _config_path else None
|
||||||
|
)
|
||||||
|
if not avatar_dir:
|
||||||
|
return web.json_response({"error": "Local avatars not configured"}, status=400)
|
||||||
|
if not os.path.realpath(avatar_val).startswith(os.path.realpath(avatar_dir) + os.sep):
|
||||||
|
return web.json_response({"error": "Avatar path outside allowed directory"}, status=400)
|
||||||
|
user_entry["avatar"] = avatar_val
|
||||||
if "notification_channels" in body:
|
if "notification_channels" in body:
|
||||||
visible = _visible_channels_for_user(user)
|
visible = _visible_channels_for_user(user)
|
||||||
user_entry["notification_channels"] = [
|
user_entry["notification_channels"] = [
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ def eventlog(host, lvl, m, service=None):
|
|||||||
"message": m,
|
"message": m,
|
||||||
}
|
}
|
||||||
data.msgs.append(msg)
|
data.msgs.append(msg)
|
||||||
|
# Cap the in-memory buffer so it doesn't grow without bound; this list is
|
||||||
|
# replayed to every dashboard client on connect and persisted in the pickle.
|
||||||
|
cap = _config.get("msg_buffer_size", 500)
|
||||||
|
if cap and len(data.msgs) > cap:
|
||||||
|
del data.msgs[:-cap]
|
||||||
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
||||||
if host:
|
if host:
|
||||||
s += f"{host} "
|
s += f"{host} "
|
||||||
|
|||||||
+55
-16
@@ -21,6 +21,8 @@ editable bool Reserved for future use — currently always False
|
|||||||
sensitive bool True when the raw value must never be shown
|
sensitive bool True when the raw value must never be shown
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from . import config_access
|
||||||
|
|
||||||
# Credential field names that should always be masked.
|
# Credential field names that should always be masked.
|
||||||
_SECRET_KEYS = frozenset({
|
_SECRET_KEYS = frozenset({
|
||||||
"password", "token", "user_key", "api_key", "secret",
|
"password", "token", "user_key", "api_key", "secret",
|
||||||
@@ -140,9 +142,14 @@ def _sanitize_channel(name, cfg):
|
|||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
def get_settings_sections(config: dict, threshold_checker=None, user=None) -> list:
|
||||||
"""Return ordered list of setting sections for the settings page.
|
"""Return ordered list of setting sections for the settings page.
|
||||||
|
|
||||||
|
*user* is an object with ``username``/``admin`` attributes, or None for
|
||||||
|
the unauthenticated (admin-equivalent) view. Non-admins get only the
|
||||||
|
channels/hosts/thresholds sections, filtered to global items plus what
|
||||||
|
they own or manage.
|
||||||
|
|
||||||
Each section:
|
Each section:
|
||||||
{
|
{
|
||||||
"title": str,
|
"title": str,
|
||||||
@@ -162,6 +169,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"sensitive": bool,
|
"sensitive": bool,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
is_admin = user is None or getattr(user, "admin", False)
|
||||||
|
username: str = getattr(user, "username", "") or ""
|
||||||
|
|
||||||
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
||||||
raw = config.get(key)
|
raw = config.get(key)
|
||||||
if sensitive:
|
if sensitive:
|
||||||
@@ -200,6 +210,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
|
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
|
||||||
if not isinstance(ch_cfg, dict):
|
if not isinstance(ch_cfg, dict):
|
||||||
continue
|
continue
|
||||||
|
if not is_admin and not config_access.user_can_use(ch_cfg, username):
|
||||||
|
continue
|
||||||
ch_type = ch_cfg.get("type", "")
|
ch_type = ch_cfg.get("type", "")
|
||||||
fields = []
|
fields = []
|
||||||
for k, v in ch_cfg.items():
|
for k, v in ch_cfg.items():
|
||||||
@@ -219,18 +231,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"type": ch_type,
|
"type": ch_type,
|
||||||
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||||
"owner": ch_cfg.get("owner"),
|
"owner": ch_cfg.get("owner"),
|
||||||
"private": bool(ch_cfg.get("private", False)),
|
"editable": is_admin or ch_cfg.get("owner") == username,
|
||||||
"min_level": ch_cfg.get("min_level", "WARNING"),
|
"min_level": ch_cfg.get("min_level", "WARNING"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- Users (show metadata only, never password hashes) ----------------
|
# ---- Users (show metadata only, never password hashes) ----------------
|
||||||
users_list = []
|
users_list = []
|
||||||
for username, attrs in (config.get("users") or {}).items():
|
for uname, attrs in (config.get("users") or {}).items():
|
||||||
if not isinstance(attrs, dict):
|
if not isinstance(attrs, dict):
|
||||||
continue
|
continue
|
||||||
users_list.append({
|
users_list.append({
|
||||||
"username": username,
|
"username": uname,
|
||||||
"full_name": attrs.get("full_name", ""),
|
"full_name": attrs.get("full_name", ""),
|
||||||
"admin": bool(attrs.get("admin", False)),
|
"admin": bool(attrs.get("admin", False)),
|
||||||
"avatar": attrs.get("avatar", ""),
|
"avatar": attrs.get("avatar", ""),
|
||||||
@@ -252,9 +264,14 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
}
|
}
|
||||||
|
|
||||||
threshold_config_list = []
|
threshold_config_list = []
|
||||||
|
raw_threshold_cfgs = config.get("threshold_configs") or {}
|
||||||
if threshold_checker is not None:
|
if threshold_checker is not None:
|
||||||
if threshold_checker.threshold_configs:
|
if threshold_checker.threshold_configs:
|
||||||
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
||||||
|
raw_cfg = raw_threshold_cfgs.get(cfg_name)
|
||||||
|
tc_owner = raw_cfg.get("owner") if isinstance(raw_cfg, dict) else None
|
||||||
|
if not is_admin and tc_owner and tc_owner != username:
|
||||||
|
continue
|
||||||
# For the default config use the merged effective set;
|
# For the default config use the merged effective set;
|
||||||
# for named overrides use only the explicitly defined metrics
|
# for named overrides use only the explicitly defined metrics
|
||||||
# (threshold_raw_configs) so inherited defaults are not repeated.
|
# (threshold_raw_configs) so inherited defaults are not repeated.
|
||||||
@@ -266,25 +283,37 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
[_tc_to_row(tc) for tc in display_metrics.values()],
|
[_tc_to_row(tc) for tc in display_metrics.values()],
|
||||||
key=lambda m: m["metric"],
|
key=lambda m: m["metric"],
|
||||||
)
|
)
|
||||||
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
|
threshold_config_list.append({
|
||||||
|
"name": cfg_name,
|
||||||
|
"metrics": metrics,
|
||||||
|
"owner": tc_owner,
|
||||||
|
"editable": is_admin or (tc_owner is not None and tc_owner == username),
|
||||||
|
})
|
||||||
elif threshold_checker.thresholds:
|
elif threshold_checker.thresholds:
|
||||||
metrics = sorted(
|
metrics = sorted(
|
||||||
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
||||||
key=lambda m: m["metric"],
|
key=lambda m: m["metric"],
|
||||||
)
|
)
|
||||||
threshold_config_list.append({"name": "default", "metrics": metrics})
|
threshold_config_list.append({"name": "default", "metrics": metrics,
|
||||||
|
"owner": None, "editable": is_admin})
|
||||||
|
|
||||||
# ---- Hosts summary ----------------------------------------------------
|
# ---- Hosts summary ----------------------------------------------------
|
||||||
hosts_list = []
|
hosts_list = []
|
||||||
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
|
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
|
||||||
if not isinstance(hcfg, dict):
|
if not isinstance(hcfg, dict):
|
||||||
continue
|
continue
|
||||||
|
managers = hcfg.get("managers", [])
|
||||||
|
if isinstance(managers, str):
|
||||||
|
managers = [managers]
|
||||||
|
if not is_admin and hcfg.get("owner") != username and username not in managers:
|
||||||
|
continue
|
||||||
hosts_list.append({
|
hosts_list.append({
|
||||||
"name": hname,
|
"name": hname,
|
||||||
"watch": bool(hcfg.get("watch", True)),
|
"watch": bool(hcfg.get("watch", True)),
|
||||||
"dyndns": bool(hcfg.get("dyndns", False)),
|
"dyndns": bool(hcfg.get("dyndns", False)),
|
||||||
"owner": hcfg.get("owner", ""),
|
"owner": hcfg.get("owner", ""),
|
||||||
"managers": hcfg.get("managers", []),
|
"is_owner": is_admin or hcfg.get("owner") == username,
|
||||||
|
"managers": managers,
|
||||||
"monitors": hcfg.get("monitors", []),
|
"monitors": hcfg.get("monitors", []),
|
||||||
"threshold_configs": (
|
"threshold_configs": (
|
||||||
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
|
||||||
@@ -309,7 +338,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
"logo": pattrs.get("logo", ""),
|
"logo": pattrs.get("logo", ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
return [
|
sections: list = [
|
||||||
{
|
{
|
||||||
"id": "network",
|
"id": "network",
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -483,16 +512,26 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
sections = [s for s in sections if s["id"] in ("channels", "hosts", "thresholds")]
|
||||||
|
for s in sections:
|
||||||
|
s["fields"] = []
|
||||||
|
return sections
|
||||||
|
|
||||||
def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
|
||||||
|
def get_settings_data(config: dict, threshold_checker=None, user=None) -> dict:
|
||||||
"""Return sections list + auxiliary data for the settings template."""
|
"""Return sections list + auxiliary data for the settings template."""
|
||||||
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
sections = get_settings_sections(config, threshold_checker=threshold_checker, user=user)
|
||||||
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
|
is_admin = user is None or getattr(user, "admin", False)
|
||||||
all_usernames = sorted((config.get("users") or {}).keys())
|
username: str = getattr(user, "username", "") or ""
|
||||||
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
|
channels = config.get("notification_channels") or {}
|
||||||
|
threshold_cfgs = config.get("threshold_configs") or {}
|
||||||
|
if not is_admin:
|
||||||
|
channels = config_access.user_channels(channels, username)
|
||||||
|
threshold_cfgs = config_access.user_threshold_configs(threshold_cfgs, username)
|
||||||
return {
|
return {
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"all_channel_names": all_channel_names,
|
"all_channel_names": sorted(channels.keys()),
|
||||||
"all_usernames": all_usernames,
|
"all_usernames": sorted((config.get("users") or {}).keys()),
|
||||||
"all_threshold_configs": all_threshold_configs,
|
"all_threshold_configs": sorted(threshold_cfgs.keys()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,15 @@
|
|||||||
var c = 0;
|
var c = 0;
|
||||||
var HBD_VERSION = "{{ hbd_version }}";
|
var HBD_VERSION = "{{ hbd_version }}";
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function hostNameHtml(data) {
|
function hostNameHtml(data) {
|
||||||
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
|
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
|
||||||
var nameHtml = data.name;
|
var nameHtml = escHtml(data.name);
|
||||||
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
|
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
|
||||||
nameHtml += ' 🥀';
|
nameHtml += ' 🥀';
|
||||||
}
|
}
|
||||||
@@ -410,11 +416,11 @@
|
|||||||
c_critical.innerHTML = "";
|
c_critical.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
c_ipv4addr.innerHTML = data.connections[0].addr;
|
c_ipv4addr.innerHTML = escHtml(data.connections[0].addr);
|
||||||
c_ipv4state.innerHTML = data.connections[0].state;
|
c_ipv4state.innerHTML = escHtml(data.connections[0].state);
|
||||||
if (data.connections.length > 1) {
|
if (data.connections.length > 1) {
|
||||||
c_ipv6addr.innerHTML = data.connections[1].addr;
|
c_ipv6addr.innerHTML = escHtml(data.connections[1].addr);
|
||||||
c_ipv6state.innerHTML = data.connections[1].state;
|
c_ipv6state.innerHTML = escHtml(data.connections[1].state);
|
||||||
}
|
}
|
||||||
var table = document.getElementById("ntablebody"); // find table to append to
|
var table = document.getElementById("ntablebody"); // find table to append to
|
||||||
table.appendChild(row); // append row to table
|
table.appendChild(row); // append row to table
|
||||||
@@ -477,7 +483,7 @@
|
|||||||
|
|
||||||
for (var i = 0; i < data.connections.length; i++) {
|
for (var i = 0; i < data.connections.length; i++) {
|
||||||
// Offset by 2 for the warning/critical count columns
|
// Offset by 2 for the warning/critical count columns
|
||||||
name_idx[data.name].cells[3 + i * 4].innerHTML = data.connections[i].addr;
|
name_idx[data.name].cells[3 + i * 4].innerHTML = escHtml(data.connections[i].addr);
|
||||||
name_idx[data.name].cells[6 + i * 4].innerHTML = formatTS(
|
name_idx[data.name].cells[6 + i * 4].innerHTML = formatTS(
|
||||||
data.connections[i].statetime
|
data.connections[i].statetime
|
||||||
);
|
);
|
||||||
@@ -497,7 +503,7 @@
|
|||||||
state = '<span class="state-overdue">overdue</span>';
|
state = '<span class="state-overdue">overdue</span>';
|
||||||
latency = "-";
|
latency = "-";
|
||||||
} else {
|
} else {
|
||||||
state = "<b>" + data.connections[i].state + "</b>";
|
state = "<b>" + escHtml(data.connections[i].state) + "</b>";
|
||||||
latency = "-";
|
latency = "-";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,12 +564,12 @@
|
|||||||
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||||
var lvl = (msg.level || "INFO").toLowerCase();
|
var lvl = (msg.level || "INFO").toLowerCase();
|
||||||
var hostVal = msg.host || '';
|
var hostVal = msg.host || '';
|
||||||
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '"') + '">';
|
var html = '<div class="log-entry log-' + escHtml(lvl) + '" data-level="' + escHtml(lvl) + '" data-host="' + escHtml(hostVal) + '">';
|
||||||
html += '<span class="log-ts">' + ts_str + '</span>';
|
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||||
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
html += '<span class="log-level">' + escHtml(msg.level || "") + '</span>';
|
||||||
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
if (msg.host) html += '<span class="log-host">' + escHtml(msg.host) + '</span>';
|
||||||
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
if (msg.service) html += '<span class="log-service">' + escHtml(msg.service) + '</span>';
|
||||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
html += '<span class="log-msg">' + escHtml(msg.message) + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
msgs.insertAdjacentHTML(state.history ? "beforeend" : "afterbegin", html);
|
msgs.insertAdjacentHTML(state.history ? "beforeend" : "afterbegin", html);
|
||||||
applyLogFilters();
|
applyLogFilters();
|
||||||
@@ -621,7 +627,7 @@
|
|||||||
<tbody id="ntablebody">
|
<tbody id="ntablebody">
|
||||||
{% for host in hosts %}
|
{% for host in hosts %}
|
||||||
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
||||||
<td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.raw_name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
|
<td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
|
||||||
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
||||||
{%- set warning_unacked = host.alert_warning_unacked -%}
|
{%- set warning_unacked = host.alert_warning_unacked -%}
|
||||||
{%- set warning_acked = host.alert_warning_acked -%}
|
{%- set warning_acked = host.alert_warning_acked -%}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
||||||
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||||
{% if current_user and current_user.admin %}
|
{% if current_user %}
|
||||||
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user and current_user.admin %}
|
{% if current_user %}
|
||||||
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">⚠ Publish Config</button>
|
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">⚠ Publish Config</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="nav-pie" title="Host alert status">
|
<div class="nav-pie" title="Host alert status">
|
||||||
|
|||||||
@@ -240,7 +240,6 @@
|
|||||||
}
|
}
|
||||||
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
|
||||||
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
|
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
|
||||||
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
|
|
||||||
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
|
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
|
||||||
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
|
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
|
||||||
.btn-sm-edit:hover { background: #666; }
|
.btn-sm-edit:hover { background: #666; }
|
||||||
@@ -465,7 +464,7 @@
|
|||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>My Channels</h2>
|
<h2>My Channels</h2>
|
||||||
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
|
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own are private to you. Global channels are managed by administrators.</p>
|
||||||
<div id="my-channels-list">
|
<div id="my-channels-list">
|
||||||
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
|
||||||
{% for ch in my_channels %}
|
{% for ch in my_channels %}
|
||||||
@@ -473,7 +472,6 @@
|
|||||||
<div class="my-ch-header">
|
<div class="my-ch-header">
|
||||||
<span class="my-ch-name">{{ ch.name | e }}</span>
|
<span class="my-ch-name">{{ ch.name | e }}</span>
|
||||||
<span class="my-ch-type">{{ ch.type | e }}</span>
|
<span class="my-ch-type">{{ ch.type | e }}</span>
|
||||||
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
|
|
||||||
<span class="my-ch-actions">
|
<span class="my-ch-actions">
|
||||||
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
|
||||||
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')">✕</button>
|
||||||
@@ -513,11 +511,6 @@
|
|||||||
<option value="CRITICAL">CRITICAL only</option>
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ch-form-row">
|
|
||||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
|
||||||
<input type="checkbox" id="my-ch-private"> Private — visible only to you
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="my-ch-modal-status" class="ch-modal-status"></div>
|
<div id="my-ch-modal-status" class="ch-modal-status"></div>
|
||||||
<div class="ch-modal-footer">
|
<div class="ch-modal-footer">
|
||||||
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
|
||||||
@@ -744,7 +737,6 @@
|
|||||||
document.getElementById('my-ch-type').value = '';
|
document.getElementById('my-ch-type').value = '';
|
||||||
document.getElementById('my-ch-type-fields').innerHTML = '';
|
document.getElementById('my-ch-type-fields').innerHTML = '';
|
||||||
document.getElementById('my-ch-min-level').value = 'WARNING';
|
document.getElementById('my-ch-min-level').value = 'WARNING';
|
||||||
document.getElementById('my-ch-private').checked = false;
|
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
try {
|
try {
|
||||||
@@ -755,7 +747,6 @@
|
|||||||
document.getElementById('my-ch-type').value = ch.type;
|
document.getElementById('my-ch-type').value = ch.type;
|
||||||
onMyChTypeChange();
|
onMyChTypeChange();
|
||||||
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
document.getElementById('my-ch-private').checked = ch.private || false;
|
|
||||||
(ch.fields || []).forEach(f => {
|
(ch.fields || []).forEach(f => {
|
||||||
const inp = document.getElementById('mychf-' + f.key);
|
const inp = document.getElementById('mychf-' + f.key);
|
||||||
if (inp) inp.value = f.value || '';
|
if (inp) inp.value = f.value || '';
|
||||||
@@ -774,14 +765,13 @@
|
|||||||
const name = document.getElementById('my-ch-name').value.trim();
|
const name = document.getElementById('my-ch-name').value.trim();
|
||||||
const type = document.getElementById('my-ch-type').value;
|
const type = document.getElementById('my-ch-type').value;
|
||||||
const minLevel = document.getElementById('my-ch-min-level').value;
|
const minLevel = document.getElementById('my-ch-min-level').value;
|
||||||
const isPrivate = document.getElementById('my-ch-private').checked;
|
|
||||||
const statusEl = document.getElementById('my-ch-modal-status');
|
const statusEl = document.getElementById('my-ch-modal-status');
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
|
||||||
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||||
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
const body = { name, type, min_level: minLevel };
|
||||||
if (_myChSchemas[type]) {
|
if (_myChSchemas[type]) {
|
||||||
(_myChSchemas[type].fields || []).forEach(sf => {
|
(_myChSchemas[type].fields || []).forEach(sf => {
|
||||||
const inp = document.getElementById('mychf-' + sf.key);
|
const inp = document.getElementById('mychf-' + sf.key);
|
||||||
|
|||||||
@@ -572,11 +572,12 @@
|
|||||||
<option value="CRITICAL">CRITICAL only</option>
|
<option value="CRITICAL">CRITICAL only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not current_user or current_user.admin %}
|
||||||
<div class="ch-form-row">
|
<div class="ch-form-row">
|
||||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
<label>Owner <span style="font-weight:normal;color:#888">(empty = global)</span></label>
|
||||||
<input type="checkbox" id="ch-private"> Private — visible only to you
|
<input type="text" id="ch-owner" placeholder="(global)" autocomplete="off">
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div id="ch-modal-status" class="ch-status"></div>
|
<div id="ch-modal-status" class="ch-status"></div>
|
||||||
<div class="ch-modal-footer">
|
<div class="ch-modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
|
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
|
||||||
@@ -594,8 +595,10 @@
|
|||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if not current_user or current_user.admin %}
|
||||||
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
|
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
|
||||||
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
|
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -709,12 +712,18 @@
|
|||||||
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
|
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
||||||
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
||||||
|
{% if h.is_owner %}
|
||||||
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
||||||
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
||||||
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
||||||
|
{% else %}
|
||||||
|
<td><span class="val-tag">{{ h.owner | e }}</span></td>
|
||||||
|
<td>{% for m in h.managers %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
|
||||||
|
<td>{% for m in h.monitors %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
|
||||||
|
{% endif %}
|
||||||
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
||||||
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
||||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
<td>{% if h.is_owner %}<button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -748,12 +757,13 @@
|
|||||||
<span class="channel-name-text">{{ ch.name | e }}</span>
|
<span class="channel-name-text">{{ ch.name | e }}</span>
|
||||||
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
|
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
|
||||||
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
|
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
|
||||||
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
|
|
||||||
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
|
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
|
||||||
|
{% if ch.editable %}
|
||||||
<span class="channel-header-actions">
|
<span class="channel-header-actions">
|
||||||
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
|
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
|
||||||
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')">✕</button>
|
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')">✕</button>
|
||||||
</span>
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-fields">
|
<div class="channel-fields">
|
||||||
{% for f in ch.fields %}
|
{% for f in ch.fields %}
|
||||||
@@ -790,13 +800,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
|
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
|
||||||
{% for tc in section.threshold_configs %}
|
{% for tc in section.threshold_configs %}
|
||||||
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
|
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"{% if not tc.editable %} data-readonly="true"{% endif %}>
|
||||||
<div class="thresh-cfg-header">
|
<div class="thresh-cfg-header">
|
||||||
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
|
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
|
||||||
{% if tc.name != 'default' %}
|
{% if (not current_user or current_user.admin) and tc.name != 'default' %}
|
||||||
|
<input type="text" class="field-input thresh-owner" value="{{ tc.owner or '' }}"
|
||||||
|
placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">
|
||||||
|
{% elif tc.owner %}
|
||||||
|
<span class="ch-owner-badge" style="margin-left:10px">{{ tc.owner | e }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if tc.editable and tc.name != 'default' %}
|
||||||
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
|
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset {% if not tc.editable %}disabled{% endif %} style="border:none;margin:0;padding:0;min-width:0">
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<table class="crud-table thresh-metric-table">
|
<table class="crud-table thresh-metric-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
@@ -845,6 +862,7 @@
|
|||||||
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||||||
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -928,6 +946,7 @@
|
|||||||
const _allChannels = {{ all_channel_names | tojson }};
|
const _allChannels = {{ all_channel_names | tojson }};
|
||||||
const _allUsers = {{ all_usernames | tojson }};
|
const _allUsers = {{ all_usernames | tojson }};
|
||||||
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
|
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
|
||||||
|
const _isAdmin = {{ 'true' if (not current_user or current_user.admin) else 'false' }};
|
||||||
|
|
||||||
// ---- Channel CRUD ----
|
// ---- Channel CRUD ----
|
||||||
let _channelSchemas = {};
|
let _channelSchemas = {};
|
||||||
@@ -981,7 +1000,8 @@
|
|||||||
document.getElementById('ch-type').value = '';
|
document.getElementById('ch-type').value = '';
|
||||||
document.getElementById('ch-type-fields').innerHTML = '';
|
document.getElementById('ch-type-fields').innerHTML = '';
|
||||||
document.getElementById('ch-min-level').value = 'WARNING';
|
document.getElementById('ch-min-level').value = 'WARNING';
|
||||||
document.getElementById('ch-private').checked = false;
|
const ownerInp = document.getElementById('ch-owner');
|
||||||
|
if (ownerInp) ownerInp.value = '';
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
// Load existing channel data via API
|
// Load existing channel data via API
|
||||||
@@ -993,7 +1013,7 @@
|
|||||||
document.getElementById('ch-type').value = ch.type;
|
document.getElementById('ch-type').value = ch.type;
|
||||||
onChTypeChange();
|
onChTypeChange();
|
||||||
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
|
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
|
||||||
document.getElementById('ch-private').checked = ch.private || false;
|
if (ownerInp) ownerInp.value = ch.owner || '';
|
||||||
(ch.fields || []).forEach(f => {
|
(ch.fields || []).forEach(f => {
|
||||||
const inp = document.getElementById('chf-' + f.key);
|
const inp = document.getElementById('chf-' + f.key);
|
||||||
if (inp) inp.value = f.value || '';
|
if (inp) inp.value = f.value || '';
|
||||||
@@ -1012,14 +1032,15 @@
|
|||||||
const name = document.getElementById('ch-name').value.trim();
|
const name = document.getElementById('ch-name').value.trim();
|
||||||
const type = document.getElementById('ch-type').value;
|
const type = document.getElementById('ch-type').value;
|
||||||
const minLevel = document.getElementById('ch-min-level').value;
|
const minLevel = document.getElementById('ch-min-level').value;
|
||||||
const isPrivate = document.getElementById('ch-private').checked;
|
|
||||||
const statusEl = document.getElementById('ch-modal-status');
|
const statusEl = document.getElementById('ch-modal-status');
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
|
||||||
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
|
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||||
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||||
|
|
||||||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
const body = { name, type, min_level: minLevel };
|
||||||
|
const ownerInp = document.getElementById('ch-owner');
|
||||||
|
if (ownerInp) body.owner = ownerInp.value.trim();
|
||||||
if (_channelSchemas[type]) {
|
if (_channelSchemas[type]) {
|
||||||
(_channelSchemas[type].fields || []).forEach(sf => {
|
(_channelSchemas[type].fields || []).forEach(sf => {
|
||||||
const inp = document.getElementById('chf-' + sf.key);
|
const inp = document.getElementById('chf-' + sf.key);
|
||||||
@@ -1177,8 +1198,11 @@
|
|||||||
watch: row.querySelector('.host-watch').checked,
|
watch: row.querySelector('.host-watch').checked,
|
||||||
dyndns: row.querySelector('.host-dyndns').checked,
|
dyndns: row.querySelector('.host-dyndns').checked,
|
||||||
};
|
};
|
||||||
const owner = row.querySelector('.host-owner').value.trim();
|
const ownerInput = row.querySelector('.host-owner');
|
||||||
|
if (ownerInput) {
|
||||||
|
const owner = ownerInput.value.trim();
|
||||||
if (owner) entry.owner = owner;
|
if (owner) entry.owner = owner;
|
||||||
|
}
|
||||||
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
|
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
|
||||||
if (managers.length) entry.managers = managers;
|
if (managers.length) entry.managers = managers;
|
||||||
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
|
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
|
||||||
@@ -1556,10 +1580,15 @@
|
|||||||
|
|
||||||
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
|
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
|
||||||
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
|
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
|
||||||
|
if (card.dataset.readonly === 'true') return;
|
||||||
const configName = card.dataset.configName
|
const configName = card.dataset.configName
|
||||||
|| (card.querySelector('.new-config-name')?.value || '').trim();
|
|| (card.querySelector('.new-config-name')?.value || '').trim();
|
||||||
if (!configName) return;
|
if (!configName) return;
|
||||||
configs[configName] = readMetrics(card);
|
const ownerInp = card.querySelector('.thresh-owner');
|
||||||
|
configs[configName] = {
|
||||||
|
owner: ownerInp ? ownerInp.value.trim() : '',
|
||||||
|
metrics: readMetrics(card),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
_staged['thresholds'] = configs;
|
_staged['thresholds'] = configs;
|
||||||
@@ -1613,6 +1642,7 @@
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="thresh-cfg-header">
|
<div class="thresh-cfg-header">
|
||||||
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
|
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
|
||||||
|
${_isAdmin ? '<input type="text" class="field-input thresh-owner" placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">' : ''}
|
||||||
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
|
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
|
|||||||
@@ -1554,6 +1554,10 @@ class ThresholdChecker:
|
|||||||
configured = self.get_thresholds_for_host(hostname)
|
configured = self.get_thresholds_for_host(hostname)
|
||||||
stale = []
|
stale = []
|
||||||
for mp in host.alert_states:
|
for mp in host.alert_states:
|
||||||
|
# connectivity.* and rtt are managed by the connection state
|
||||||
|
# machine, not by threshold config — never purge them.
|
||||||
|
if mp == "rtt" or mp.startswith("connectivity"):
|
||||||
|
continue
|
||||||
if self._find_threshold(configured, mp)[0] is not None:
|
if self._find_threshold(configured, mp)[0] is not None:
|
||||||
continue
|
continue
|
||||||
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
|
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
|
||||||
|
|||||||
+64
-6
@@ -16,6 +16,11 @@ from . import notify as notify_mod
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
# Plugin data is declared stale after this many collection intervals without a
|
||||||
|
# fresh sample. >= 2 so a single missed sample never purges live data (the user
|
||||||
|
# directive: wait at least two full intervals after recovery before going stale).
|
||||||
|
_STALE_INTERVAL_MULTIPLIER = 3
|
||||||
|
|
||||||
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
|
||||||
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
|
||||||
# Python's socket module on all platforms
|
# Python's socket module on all platforms
|
||||||
@@ -266,10 +271,15 @@ def restore_connection_timers(hbdclass, ctx):
|
|||||||
for afam, conn in list(host.connections.items()):
|
for afam, conn in list(host.connections.items()):
|
||||||
state = conn.getstate()
|
state = conn.getstate()
|
||||||
if state == hbdclass.Connection.DOWN:
|
if state == hbdclass.Connection.DOWN:
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
|
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
|
||||||
|
|
||||||
|
if state == hbdclass.Connection.UNKNOWN:
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
|
continue
|
||||||
|
|
||||||
if state == hbdclass.Connection.UP and interval > 0:
|
if state == hbdclass.Connection.UP and interval > 0:
|
||||||
elapsed = now - conn.lastbeat
|
elapsed = now - conn.lastbeat
|
||||||
# Give hosts one full (interval + grace) of extra time on startup
|
# Give hosts one full (interval + grace) of extra time on startup
|
||||||
@@ -300,6 +310,10 @@ def restore_connection_timers(hbdclass, ctx):
|
|||||||
"Restored OVERDUE timer %s/%s: %.0fs remaining",
|
"Restored OVERDUE timer %s/%s: %.0fs remaining",
|
||||||
uname, afam, remaining,
|
uname, afam, remaining,
|
||||||
)
|
)
|
||||||
|
# Ensure the connectivity alert is set — it may be missing if
|
||||||
|
# hbd was shut down before the on_overdue callback had a chance
|
||||||
|
# to record it.
|
||||||
|
_set_connectivity_alert(host, afam, "CRITICAL")
|
||||||
restored += 1
|
restored += 1
|
||||||
|
|
||||||
logger.info("Restored timers for %d connection(s)", restored)
|
logger.info("Restored timers for %d connection(s)", restored)
|
||||||
@@ -385,20 +399,50 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
plugin_name = msg.get("plugin")
|
plugin_name = msg.get("plugin")
|
||||||
if plugin_name:
|
if plugin_name:
|
||||||
# Extract plugin fields, dropping protocol metadata fields
|
# Extract plugin fields, dropping protocol metadata fields
|
||||||
|
# (_interval is the client-declared collection interval, not a metric).
|
||||||
plugin_data = {k: v for k, v in msg.items()
|
plugin_data = {k: v for k, v in msg.items()
|
||||||
if k not in ("ID", "plugin", "id", "name")}
|
if k not in ("ID", "plugin", "id", "name", "_interval")}
|
||||||
# Store plugin data with timestamp
|
# Store plugin data with timestamp
|
||||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||||
# Reset stale timer — 3× the heartbeat interval (min 60 s)
|
|
||||||
stale_timeout = max(host.interval * 3, 60)
|
# Set the stale timer. Prefer the interval the client declares: it is
|
||||||
host.reset_plugin_timer(plugin_name, stale_timeout,
|
# the true collection cadence and is correct from the very first
|
||||||
|
# post-recovery sample, so we never purge data too early after an
|
||||||
|
# outage. interval == 0 means a collect-once InfoPlugin that never
|
||||||
|
# goes stale. Legacy clients omit _interval, so fall back to inferring
|
||||||
|
# the cadence from the gap between the last two received samples.
|
||||||
|
history = host.plugin_data.get(plugin_name, [])
|
||||||
|
declared = msg.get("_interval")
|
||||||
|
if declared is not None:
|
||||||
|
if declared > 0:
|
||||||
|
host.reset_plugin_timer(plugin_name, declared * _STALE_INTERVAL_MULTIPLIER,
|
||||||
_make_plugin_stale_callback(uname, ctx))
|
_make_plugin_stale_callback(uname, ctx))
|
||||||
|
else:
|
||||||
|
host.cancel_plugin_timer(plugin_name)
|
||||||
|
elif len(history) >= 2:
|
||||||
|
plugin_interval = max(history[-1][0] - history[-2][0], 1)
|
||||||
|
host.reset_plugin_timer(plugin_name, plugin_interval * _STALE_INTERVAL_MULTIPLIER,
|
||||||
|
_make_plugin_stale_callback(uname, ctx))
|
||||||
|
else:
|
||||||
|
host.cancel_plugin_timer(plugin_name)
|
||||||
|
|
||||||
|
# Remove alert states for metrics present in the previous sample but
|
||||||
|
# absent now (e.g. a nagios check removed from configuration).
|
||||||
|
if len(history) >= 2:
|
||||||
|
prev_keys = set(history[-2][1].keys())
|
||||||
|
curr_keys = set(plugin_data.keys())
|
||||||
|
for metric_name in prev_keys - curr_keys:
|
||||||
|
metric_path = f"{plugin_name}.{metric_name}"
|
||||||
|
if host.alert_states.pop(metric_path, None) is not None:
|
||||||
|
eventlog(uname, "INFO", f"stale check removed: {metric_path}")
|
||||||
|
if (prev_keys - curr_keys) and msg_to_websockets:
|
||||||
|
msg_to_websockets("host", host.stateinfo())
|
||||||
|
|
||||||
# If os_info reports an owner and none is configured server-side, apply it
|
# If os_info reports an owner and none is configured server-side, apply it
|
||||||
if plugin_name == "os_info":
|
if plugin_name == "os_info":
|
||||||
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
||||||
default_owner = config_mod.get_default_owner(cfg)
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
inferred_owner = config_owner or plugin_data.get("owner") or default_owner
|
||||||
host.owner = inferred_owner
|
host.owner = inferred_owner
|
||||||
logger.info(f"owner for {uname} is {host.owner}")
|
logger.info(f"owner for {uname} is {host.owner}")
|
||||||
if DEBUG > 1:
|
if DEBUG > 1:
|
||||||
@@ -453,6 +497,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
boot = msg.get("boot", 0)
|
boot = msg.get("boot", 0)
|
||||||
|
|
||||||
if boot:
|
if boot:
|
||||||
|
# hbc was stared with a -b flag
|
||||||
eventlog(uname, "INFO", "booted")
|
eventlog(uname, "INFO", "booted")
|
||||||
if host.watched:
|
if host.watched:
|
||||||
asyncio.create_task(notify_mod.send_notification(
|
asyncio.create_task(notify_mod.send_notification(
|
||||||
@@ -460,11 +505,24 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
||||||
))
|
))
|
||||||
if message:
|
if message:
|
||||||
eventlog(uname, "INFO", "msg: %s" % message, service=service)
|
eventlog(uname, "INFO", message, service=service)
|
||||||
|
|
||||||
if conn.getstate() != hbdcls.Connection.UP:
|
if conn.getstate() != hbdcls.Connection.UP:
|
||||||
|
# Transition to UP and log/notify if appropriate
|
||||||
lasts = conn.state
|
lasts = conn.state
|
||||||
d = conn.newstate(hbdcls.Connection.UP, now)
|
d = conn.newstate(hbdcls.Connection.UP, now)
|
||||||
|
# On reboot, pre-boot plugin data and derived alerts are stale.
|
||||||
|
# Cancel all plugin timers and wipe plugin state so timers restart
|
||||||
|
# cleanly from the first two post-boot samples.
|
||||||
|
for pname in list(host.plugin_timers):
|
||||||
|
host.cancel_plugin_timer(pname)
|
||||||
|
host.plugin_data.clear()
|
||||||
|
stale_plugin_keys = [
|
||||||
|
k for k in host.alert_states
|
||||||
|
if k not in ("rtt",) and not k.startswith("connectivity.")
|
||||||
|
]
|
||||||
|
for k in stale_plugin_keys:
|
||||||
|
del host.alert_states[k]
|
||||||
# Clear connectivity alert now that the host is back up
|
# Clear connectivity alert now that the host is back up
|
||||||
_set_connectivity_alert(host, conn.afam, "OK")
|
_set_connectivity_alert(host, conn.afam, "OK")
|
||||||
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ async def handler(request):
|
|||||||
# the client knows to append rather than prepend).
|
# the client knows to append rather than prepend).
|
||||||
if data.msgs:
|
if data.msgs:
|
||||||
try:
|
try:
|
||||||
for m in reversed(data.msgs):
|
for m in reversed(data.msgs[-30:]):
|
||||||
host_name = m.get("host") if isinstance(m, dict) else None
|
host_name = m.get("host") if isinstance(m, dict) else None
|
||||||
if not host_name or _user_can_see_host(user, host_name):
|
if not host_name or _user_can_see_host(user, host_name):
|
||||||
await ws.send_str(json.dumps({"type": "message", "data": m, "history": True}))
|
await ws.send_str(json.dumps({"type": "message", "data": m, "history": True}))
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.3.10"
|
version = "5.3.12"
|
||||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+11
-4
@@ -667,6 +667,7 @@ static void plugin_os_info(conn_t *c, const config_t *cfg) {
|
|||||||
if (osver[0]) kv_set(&d, "distro_version_id", osver);
|
if (osver[0]) kv_set(&d, "distro_version_id", osver);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
kv_set_int(&d, "_interval", 0); /* InfoPlugin: collect-once, never stale */
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGI("sent os_info");
|
LOGI("sent os_info");
|
||||||
}
|
}
|
||||||
@@ -781,6 +782,7 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
}
|
}
|
||||||
read_cpu_extras(&d);
|
read_cpu_extras(&d);
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->cpu_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent cpu_monitor");
|
LOGD("sent cpu_monitor");
|
||||||
}
|
}
|
||||||
@@ -796,7 +798,7 @@ static void plugin_cpu_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
static void mem_send(conn_t *c,
|
static void mem_send(conn_t *c,
|
||||||
long long tot, long long used, long long av, long long fr,
|
long long tot, long long used, long long av, long long fr,
|
||||||
long long act, long long ina, long long cac, long long buf,
|
long long act, long long ina, long long cac, long long buf,
|
||||||
long long stot, long long sused) {
|
long long stot, long long sused, int interval) {
|
||||||
kvdict_t d; kv_clear(&d);
|
kvdict_t d; kv_clear(&d);
|
||||||
kv_set(&d, "plugin", "memory_monitor");
|
kv_set(&d, "plugin", "memory_monitor");
|
||||||
kv_set_ull(&d, "memory_total", (unsigned long long)tot);
|
kv_set_ull(&d, "memory_total", (unsigned long long)tot);
|
||||||
@@ -819,6 +821,7 @@ static void mem_send(conn_t *c,
|
|||||||
kv_set(&d, "swap_percent", pct);
|
kv_set(&d, "swap_percent", pct);
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent memory_monitor");
|
LOGD("sent memory_monitor");
|
||||||
}
|
}
|
||||||
@@ -863,7 +866,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
/* values from /proc/meminfo are in kB */
|
/* values from /proc/meminfo are in kB */
|
||||||
mem_send(c, tot*1024, used*1024, av*1024, fr*1024,
|
mem_send(c, tot*1024, used*1024, av*1024, fr*1024,
|
||||||
act*1024, ina*1024, cac*1024, buf*1024,
|
act*1024, ina*1024, cac*1024, buf*1024,
|
||||||
stot*1024, (stot-sfr)*1024);
|
stot*1024, (stot-sfr)*1024, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#elif defined(__FreeBSD__) || defined(__DragonFly__)
|
#elif defined(__FreeBSD__) || defined(__DragonFly__)
|
||||||
@@ -889,7 +892,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
long long cac = (long long)v_cache * ps;
|
long long cac = (long long)v_cache * ps;
|
||||||
long long av = fr + ina + cac; if (av > tot) av = tot;
|
long long av = fr + ina + cac; if (av > tot) av = tot;
|
||||||
long long used = tot - av;
|
long long used = tot - av;
|
||||||
mem_send(c, tot, used, av, fr, act, ina, cac, 0, 0, 0);
|
mem_send(c, tot, used, av, fr, act, ina, cac, 0, 0, 0, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#elif defined(__NetBSD__)
|
#elif defined(__NetBSD__)
|
||||||
@@ -910,7 +913,7 @@ static void plugin_memory_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
long long used = tot - av;
|
long long used = tot - av;
|
||||||
long long stot = (long long)uvm.swpages * ps;
|
long long stot = (long long)uvm.swpages * ps;
|
||||||
long long sinuse = (long long)uvm.swpginuse * ps;
|
long long sinuse = (long long)uvm.swpginuse * ps;
|
||||||
mem_send(c, tot, used, av, fr, act, ina, 0, 0, stot, sinuse);
|
mem_send(c, tot, used, av, fr, act, ina, 0, 0, stot, sinuse, cfg->mem_interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif /* platform memory */
|
#endif /* platform memory */
|
||||||
@@ -962,6 +965,7 @@ static void plugin_disk_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
char *jval = malloc(MAX_VAL + 1);
|
char *jval = malloc(MAX_VAL + 1);
|
||||||
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "partitions", jval); free(jval); }
|
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "partitions", jval); free(jval); }
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->disk_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
free(json);
|
free(json);
|
||||||
LOGD("sent disk_monitor");
|
LOGD("sent disk_monitor");
|
||||||
@@ -1067,6 +1071,7 @@ static void plugin_network_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
char *jval = malloc(MAX_VAL + 1);
|
char *jval = malloc(MAX_VAL + 1);
|
||||||
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "interfaces", jval); free(jval); }
|
if (jval) { snprintf(jval, MAX_VAL, "@%s", json); kv_set(&d, "interfaces", jval); free(jval); }
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->net_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
free(json);
|
free(json);
|
||||||
LOGD("sent network_monitor");
|
LOGD("sent network_monitor");
|
||||||
@@ -1125,6 +1130,7 @@ static void plugin_ping_monitor(conn_t *c, const config_t *cfg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->ping_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent ping_monitor");
|
LOGD("sent ping_monitor");
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1200,7 @@ static void plugin_nagios_runner(conn_t *c, const config_t *cfg) {
|
|||||||
parse_perfdata(output, &d, name);
|
parse_perfdata(output, &d, name);
|
||||||
}
|
}
|
||||||
kv_set_dbl(&d, "_timestamp", now_ts());
|
kv_set_dbl(&d, "_timestamp", now_ts());
|
||||||
|
kv_set_int(&d, "_interval", cfg->nagios_interval);
|
||||||
conn_send(c, "PLG", &d);
|
conn_send(c, "PLG", &d);
|
||||||
LOGD("sent nagios_runner");
|
LOGD("sent nagios_runner");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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.10"
|
__version__ = "5.3.12"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
@@ -955,7 +955,7 @@ async def _run_info_plugins(conn: AsyncConnection, plugins: List[Plugin]):
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
log.info("sent %s", plugin.name)
|
log.info("sent %s", plugin.name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("%s collect: %s", plugin.name, e)
|
log.error("%s collect: %s", plugin.name, e)
|
||||||
@@ -968,7 +968,7 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
|
|||||||
try:
|
try:
|
||||||
data = await plugin.collect()
|
data = await plugin.collect()
|
||||||
if data:
|
if data:
|
||||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
|
||||||
log.debug("sent %s", plugin.name)
|
log.debug("sent %s", plugin.name)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
# PyInstaller spec for hbc_windows.exe
|
||||||
|
# Build with: pyinstaller hbc_windows.spec
|
||||||
|
#
|
||||||
|
# Requirements (on Windows):
|
||||||
|
# pip install pyinstaller
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['hbc_windows.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['tkinter', 'unittest', 'email', 'html', 'http', 'urllib', 'xml'],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zlib_archive, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='hbc_windows',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=None,
|
||||||
|
version=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Install hbc_windows.exe as a Windows Service using NSSM.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Installs the HeartBeat Client as a Windows Service that starts automatically.
|
||||||
|
Requires NSSM (Non-Sucking Service Manager) in PATH or alongside this script.
|
||||||
|
Requires hbc_windows.exe built via: pyinstaller hbc_windows.spec
|
||||||
|
|
||||||
|
.PARAMETER Server
|
||||||
|
HBD server hostname or IP address (required).
|
||||||
|
|
||||||
|
.PARAMETER ExePath
|
||||||
|
Path to hbc_windows.exe. Defaults to the directory containing this script.
|
||||||
|
|
||||||
|
.PARAMETER ServiceName
|
||||||
|
Windows service name. Default: heartbeat-client
|
||||||
|
|
||||||
|
.PARAMETER ConfigFile
|
||||||
|
Path to hbc.json config file. Optional.
|
||||||
|
|
||||||
|
.PARAMETER LogFile
|
||||||
|
Path to log file. Default: C:\ProgramData\heartbeat\hbc.log
|
||||||
|
|
||||||
|
.PARAMETER Interval
|
||||||
|
Heartbeat interval in seconds. Default: 10
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\install_hbc_windows.ps1 -Server hbd.example.com
|
||||||
|
.\install_hbc_windows.ps1 -Server hbd.example.com -ConfigFile C:\ProgramData\heartbeat\hbc.json
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Server,
|
||||||
|
|
||||||
|
[string]$ExePath = "",
|
||||||
|
[string]$ServiceName = "heartbeat-client",
|
||||||
|
[string]$ConfigFile = "",
|
||||||
|
[string]$LogFile = "C:\ProgramData\heartbeat\hbc.log",
|
||||||
|
[int]$Interval = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Locate hbc_windows.exe
|
||||||
|
if ($ExePath -eq "") {
|
||||||
|
$ExePath = Join-Path $PSScriptRoot "hbc_windows.exe"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $ExePath)) {
|
||||||
|
Write-Error "hbc_windows.exe not found at: $ExePath`nBuild it first with: pyinstaller hbc_windows.spec"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locate NSSM
|
||||||
|
$nssm = Get-Command nssm -ErrorAction SilentlyContinue
|
||||||
|
if (-not $nssm) {
|
||||||
|
$nssmLocal = Join-Path $PSScriptRoot "nssm.exe"
|
||||||
|
if (Test-Path $nssmLocal) {
|
||||||
|
$nssm = $nssmLocal
|
||||||
|
} else {
|
||||||
|
Write-Error "nssm.exe not found in PATH or alongside this script.`nDownload from https://nssm.cc/download"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$nssm = $nssm.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build argument list
|
||||||
|
$args_list = "--daemon $Server"
|
||||||
|
if ($ConfigFile -ne "") {
|
||||||
|
$args_list = "--daemon -c `"$ConfigFile`" $Server"
|
||||||
|
}
|
||||||
|
if ($LogFile -ne "") {
|
||||||
|
$args_list = "$args_list --log-file `"$LogFile`""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
$dataDir = "C:\ProgramData\heartbeat"
|
||||||
|
if (-not (Test-Path $dataDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dataDir | Out-Null
|
||||||
|
Write-Host "Created $dataDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove existing service if present
|
||||||
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host "Removing existing service '$ServiceName'..."
|
||||||
|
& $nssm stop $ServiceName 2>$null
|
||||||
|
& $nssm remove $ServiceName confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install service
|
||||||
|
Write-Host "Installing service '$ServiceName'..."
|
||||||
|
& $nssm install $ServiceName $ExePath $args_list
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "nssm install failed (exit $LASTEXITCODE)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure service
|
||||||
|
& $nssm set $ServiceName DisplayName "HeartBeat Client"
|
||||||
|
& $nssm set $ServiceName Description "Sends heartbeat and plugin metrics to the HBD monitoring server."
|
||||||
|
& $nssm set $ServiceName Start SERVICE_AUTO_START
|
||||||
|
& $nssm set $ServiceName AppStdout (Join-Path $dataDir "nssm_stdout.log")
|
||||||
|
& $nssm set $ServiceName AppStderr (Join-Path $dataDir "nssm_stderr.log")
|
||||||
|
& $nssm set $ServiceName AppRotateFiles 1
|
||||||
|
& $nssm set $ServiceName AppRotateBytes 5242880
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
Write-Host "Starting service '$ServiceName'..."
|
||||||
|
& $nssm start $ServiceName
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning "Service installed but failed to start — check logs in $dataDir"
|
||||||
|
} else {
|
||||||
|
Write-Host "Service '$ServiceName' started successfully."
|
||||||
|
Write-Host "Log file: $LogFile"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Useful commands:"
|
||||||
|
Write-Host " nssm status $ServiceName"
|
||||||
|
Write-Host " nssm stop $ServiceName"
|
||||||
|
Write-Host " nssm restart $ServiceName"
|
||||||
|
Write-Host " nssm remove $ServiceName confirm"
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Tests for ownership rules and scoped config merges (hbd.server.config_access)."""
|
||||||
|
import pytest
|
||||||
|
from hbd.server import config_access as ca
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Visibility helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_is_global_when_no_owner():
|
||||||
|
assert ca.is_global({"type": "pushover"})
|
||||||
|
assert ca.is_global({"type": "email", "owner": ""})
|
||||||
|
assert ca.is_global(None) # non-dict is treated as global
|
||||||
|
assert not ca.is_global({"type": "email", "owner": "alice"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_can_use_global_or_own():
|
||||||
|
assert ca.user_can_use({"type": "pushover"}, "alice")
|
||||||
|
assert ca.user_can_use({"owner": "alice"}, "alice")
|
||||||
|
assert not ca.user_can_use({"owner": "bob"}, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
HOSTS = {
|
||||||
|
"web1": {"owner": "alice", "watch": True},
|
||||||
|
"web2": {"owner": "bob", "managers": ["alice"]},
|
||||||
|
"web3": {"owner": "bob", "managers": "carol"}, # string manager form
|
||||||
|
"web4": {"watch": True}, # unowned
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_hosts_owner_and_manager():
|
||||||
|
assert set(ca.user_hosts(HOSTS, "alice")) == {"web1", "web2"}
|
||||||
|
assert set(ca.user_hosts(HOSTS, "carol")) == {"web3"}
|
||||||
|
assert ca.user_hosts(HOSTS, "dave") == {}
|
||||||
|
assert ca.user_hosts(None, "alice") == {}
|
||||||
|
|
||||||
|
|
||||||
|
CHANNELS = {
|
||||||
|
"global_ch": {"type": "pushover"},
|
||||||
|
"alice_ch": {"type": "email", "owner": "alice"},
|
||||||
|
"bob_ch": {"type": "signal", "owner": "bob"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_channels_global_plus_own():
|
||||||
|
assert set(ca.user_channels(CHANNELS, "alice")) == {"global_ch", "alice_ch"}
|
||||||
|
assert set(ca.user_channels(CHANNELS, "dave")) == {"global_ch"}
|
||||||
|
|
||||||
|
|
||||||
|
TCS = {
|
||||||
|
"default": {"thresholds": {}},
|
||||||
|
"alice_tc": {"owner": "alice", "thresholds": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_threshold_configs_global_plus_own():
|
||||||
|
assert set(ca.user_threshold_configs(TCS, "alice")) == {"default", "alice_tc"}
|
||||||
|
assert set(ca.user_threshold_configs(TCS, "bob")) == {"default"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# merge_hosts_scoped
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MERGE_HOSTS = {
|
||||||
|
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
|
||||||
|
"managed": {"owner": "bob", "managers": ["alice"], "watch": True,
|
||||||
|
"notification_channels": ["bob_ch"]},
|
||||||
|
"foreign": {"owner": "bob", "watch": False},
|
||||||
|
}
|
||||||
|
MERGE_CHANNELS = {
|
||||||
|
"global_ch": {"type": "pushover"},
|
||||||
|
"alice_ch": {"type": "email", "owner": "alice"},
|
||||||
|
"bob_ch": {"type": "signal", "owner": "bob"},
|
||||||
|
}
|
||||||
|
MERGE_TCS = {"alice_tc": {"owner": "alice"}, "bob_tc": {"owner": "bob"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_hosts(payload, existing=None):
|
||||||
|
return ca.merge_hosts_scoped(
|
||||||
|
existing if existing is not None else dict(MERGE_HOSTS),
|
||||||
|
payload, "alice", MERGE_CHANNELS, MERGE_TCS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_payload(**overrides):
|
||||||
|
"""Payload covering alice's full visible subset, with per-host overrides."""
|
||||||
|
p = {
|
||||||
|
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
|
||||||
|
"managed": {"watch": True, "notification_channels": ["bob_ch"]},
|
||||||
|
}
|
||||||
|
p.update(overrides)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_preserves_foreign_hosts():
|
||||||
|
result = _merge_hosts(_full_payload())
|
||||||
|
assert result["foreign"] == {"owner": "bob", "watch": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_edits_settings():
|
||||||
|
result = _merge_hosts(_full_payload(managed={"watch": False, "dyndns": True,
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
assert result["managed"]["watch"] is False
|
||||||
|
assert result["managed"]["dyndns"] is True
|
||||||
|
# access fields carried over untouched
|
||||||
|
assert result["managed"]["owner"] == "bob"
|
||||||
|
assert result["managed"]["managers"] == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_change_owner():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managed"):
|
||||||
|
_merge_hosts(_full_payload(managed={"watch": True, "owner": "alice"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_change_managers():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managers"):
|
||||||
|
_merge_hosts(_full_payload(managed={"watch": True, "managers": ["alice", "carol"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_same_access_values_ok():
|
||||||
|
# Submitting unchanged access fields is not a violation.
|
||||||
|
result = _merge_hosts(_full_payload(managed={"watch": True, "owner": "bob",
|
||||||
|
"managers": ["alice"],
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
assert result["managed"]["owner"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_manager_cannot_delete():
|
||||||
|
payload = {"mine": {"owner": "alice", "watch": True}} # 'managed' missing
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="managed"):
|
||||||
|
_merge_hosts(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_can_delete():
|
||||||
|
payload = _full_payload()
|
||||||
|
del payload["mine"]
|
||||||
|
result = _merge_hosts(payload)
|
||||||
|
assert "mine" not in result
|
||||||
|
assert "managed" in result and "foreign" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_can_transfer_ownership():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "bob", "watch": True}))
|
||||||
|
assert result["mine"]["owner"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_new_host_owner_forced():
|
||||||
|
result = _merge_hosts(_full_payload(newhost={"watch": True, "owner": "bob"}))
|
||||||
|
assert result["newhost"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_cannot_touch_foreign_host():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="foreign"):
|
||||||
|
_merge_hosts(_full_payload(foreign={"watch": True}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_cannot_add_foreign_private_channel():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_ch"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"notification_channels": ["bob_ch"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_keeps_preexisting_foreign_assignment():
|
||||||
|
# 'managed' already has bob_ch; keeping it is fine.
|
||||||
|
result = _merge_hosts(_full_payload())
|
||||||
|
assert result["managed"]["notification_channels"] == ["bob_ch"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_can_add_global_and_own_channel():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"notification_channels": ["global_ch", "alice_ch"]}))
|
||||||
|
assert result["mine"]["notification_channels"] == ["global_ch", "alice_ch"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_threshold_assignment_validated():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"threshold_config": ["bob_tc"]}))
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
|
||||||
|
"threshold_config": ["default", "alice_tc"]}))
|
||||||
|
assert result["mine"]["threshold_config"] == ["default", "alice_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_rejects_unknown_fields():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="sneaky"):
|
||||||
|
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True, "sneaky": 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_hosts_owner_clearing_list_removes_key():
|
||||||
|
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True}))
|
||||||
|
assert "notification_channels" not in result["mine"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# merge_threshold_configs_scoped
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MERGE_EXISTING_TCS = {
|
||||||
|
"default": {"thresholds": {"cpu": {"load": {"warning": 2}}}},
|
||||||
|
"alice_tc": {"owner": "alice", "thresholds": {"cpu": {"load": {"warning": 3}}}},
|
||||||
|
"bob_tc": {"owner": "bob", "thresholds": {}},
|
||||||
|
"global_tc": {"thresholds": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tcs(payload):
|
||||||
|
return ca.merge_threshold_configs_scoped(dict(MERGE_EXISTING_TCS), payload, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_new_config_owner_forced():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {}},
|
||||||
|
"new_tc": {"thresholds": {}, "owner": "bob"}})
|
||||||
|
assert result["new_tc"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_edit_own():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {"mem": {"used": {"warning": 90}}}}})
|
||||||
|
assert result["alice_tc"]["thresholds"] == {"mem": {"used": {"warning": 90}}}
|
||||||
|
assert result["alice_tc"]["owner"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_delete_own_when_missing():
|
||||||
|
result = _merge_tcs({})
|
||||||
|
assert "alice_tc" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_preserves_global_and_foreign():
|
||||||
|
result = _merge_tcs({"alice_tc": {"thresholds": {}}})
|
||||||
|
assert result["default"] == MERGE_EXISTING_TCS["default"]
|
||||||
|
assert result["bob_tc"] == MERGE_EXISTING_TCS["bob_tc"]
|
||||||
|
assert result["global_tc"] == MERGE_EXISTING_TCS["global_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_default():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="default"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "default": {"thresholds": {}}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_global_name_collision():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="global_tc"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "global_tc": {"thresholds": {}}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tcs_rejects_foreign_owned():
|
||||||
|
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
|
||||||
|
_merge_tcs({"alice_tc": {"thresholds": {}}, "bob_tc": {"thresholds": {}}})
|
||||||
@@ -171,3 +171,28 @@ def test_write_path_preserves_oauth_client_secret(tmp_path):
|
|||||||
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
|
||||||
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- threshold form payload shape ----
|
||||||
|
|
||||||
|
def test_build_threshold_configs_new_shape_with_owner():
|
||||||
|
form = {
|
||||||
|
"servers": {
|
||||||
|
"owner": "alice",
|
||||||
|
"metrics": {"cpu_monitor.load_15min": {"operator": ">", "warning": 4.0,
|
||||||
|
"critical": 8.0, "enabled": True}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = http._build_threshold_configs_from_form(form)
|
||||||
|
assert result["servers"]["owner"] == "alice"
|
||||||
|
assert result["servers"]["thresholds"]["cpu_monitor"]["load_15min"]["warning"] == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_threshold_configs_empty_owner_means_global():
|
||||||
|
form = {"servers": {"owner": "", "metrics": {"rtt": {"warning": 100.0}}}}
|
||||||
|
result = http._build_threshold_configs_from_form(form)
|
||||||
|
assert "owner" not in result["servers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_threshold_configs_ignores_entries_without_metrics():
|
||||||
|
assert http._build_threshold_configs_from_form({"bad": {"owner": "x"}}) == {}
|
||||||
|
|||||||
@@ -86,61 +86,53 @@ def test_delete_channel_persisted_after_write(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Visibility logic (mirrors http.py _visible_channels_for_user)
|
# Visibility logic (owner-presence rule, hbd.server.config_access)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from hbd.server import config_access as ca # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _visible(config, user):
|
def _visible(config, user):
|
||||||
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
|
|
||||||
all_channels = config.get("notification_channels") or {}
|
all_channels = config.get("notification_channels") or {}
|
||||||
if user.get("admin"):
|
if user.get("admin"):
|
||||||
return set(all_channels.keys())
|
return set(all_channels.keys())
|
||||||
username = user["username"]
|
return set(ca.user_channels(all_channels, user["username"]))
|
||||||
return {
|
|
||||||
name for name, cfg in all_channels.items()
|
|
||||||
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_VISIBILITY = {
|
CONFIG_VISIBILITY = {
|
||||||
"notification_channels": {
|
"notification_channels": {
|
||||||
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
"alice_priv": {"type": "email", "owner": "alice", "private": True,
|
"alice_priv": {"type": "email", "owner": "alice",
|
||||||
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
|
||||||
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
|
"bob_priv": {"type": "signal", "owner": "bob", "user": "+1", "recipient": "+2"},
|
||||||
"user": "+1", "recipient": "+2"},
|
"stale_flag": {"type": "pushover", "token": "t2", "user": "u2", "private": True},
|
||||||
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_public_channel_visible_to_all():
|
def test_global_channel_visible_to_all():
|
||||||
for uname in ("alice", "bob", "carol"):
|
for uname in ("alice", "bob", "carol"):
|
||||||
user = {"username": uname, "admin": False}
|
assert "pub_ch" in _visible(CONFIG_VISIBILITY, {"username": uname, "admin": False})
|
||||||
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
|
|
||||||
|
|
||||||
|
|
||||||
def test_private_channel_visible_only_to_owner():
|
def test_owned_channel_visible_only_to_owner():
|
||||||
alice = {"username": "alice", "admin": False}
|
alice = {"username": "alice", "admin": False}
|
||||||
bob = {"username": "bob", "admin": False}
|
bob = {"username": "bob", "admin": False}
|
||||||
carol = {"username": "carol", "admin": False}
|
|
||||||
|
|
||||||
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
|
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
|
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
|
||||||
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
|
|
||||||
|
|
||||||
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
|
||||||
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sees_all_channels():
|
def test_admin_sees_all_channels():
|
||||||
admin = {"username": "adminuser", "admin": True}
|
admin = {"username": "adminuser", "admin": True}
|
||||||
visible = _visible(CONFIG_VISIBILITY, admin)
|
assert _visible(CONFIG_VISIBILITY, admin) == {"pub_ch", "alice_priv", "bob_priv", "stale_flag"}
|
||||||
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_owned_channel_is_public_by_default():
|
def test_stale_private_flag_without_owner_is_global():
|
||||||
|
"""Owner-presence is the single signal; a leftover private flag is ignored."""
|
||||||
alice = {"username": "alice", "admin": False}
|
alice = {"username": "alice", "admin": False}
|
||||||
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
|
assert "stale_flag" in _visible(CONFIG_VISIBILITY, alice)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+102
-10
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
|
|||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
for s in sections:
|
for s in sections:
|
||||||
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
|
||||||
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
|
assert s["section_mode"] in ("form", "yaml", "channels", "hosts", "thresholds")
|
||||||
|
|
||||||
|
|
||||||
def test_sections_have_api_section():
|
def test_sections_have_api_section():
|
||||||
@@ -42,15 +42,13 @@ def test_network_section_has_editable_fields():
|
|||||||
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
assert len(editable) >= 2 # hbd_port, ws_port at minimum
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_sections_have_correct_mode():
|
def test_thresholds_and_dns_section_modes():
|
||||||
sections = settings_mod.get_settings_sections(CFG)
|
sections = settings_mod.get_settings_sections(CFG)
|
||||||
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
|
by_id = {s["id"]: s for s in sections}
|
||||||
assert "channels" not in yaml_sections # now uses "channels" mode
|
assert by_id["thresholds"]["section_mode"] == "thresholds"
|
||||||
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
assert by_id["thresholds"]["api_section"] == "thresholds"
|
||||||
assert "thresholds" in yaml_sections
|
assert by_id["dns"]["section_mode"] == "form"
|
||||||
assert "dns" in yaml_sections
|
assert by_id["dns"]["api_section"] == "dns"
|
||||||
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
|
|
||||||
assert yaml_sections["dns"]["api_section"] == "dns"
|
|
||||||
|
|
||||||
|
|
||||||
def test_hosts_section_uses_hosts_mode():
|
def test_hosts_section_uses_hosts_mode():
|
||||||
@@ -70,7 +68,7 @@ def test_channels_section_uses_channels_mode():
|
|||||||
assert ch["name"] == "pushover_ops"
|
assert ch["name"] == "pushover_ops"
|
||||||
assert ch["type"] == "pushover"
|
assert ch["type"] == "pushover"
|
||||||
assert "owner" in ch
|
assert "owner" in ch
|
||||||
assert "private" in ch
|
assert "editable" in ch
|
||||||
|
|
||||||
|
|
||||||
def test_channel_type_schemas_exported():
|
def test_channel_type_schemas_exported():
|
||||||
@@ -112,3 +110,97 @@ def test_users_section_has_user_list():
|
|||||||
assert users_sec["users"][0]["username"] == "alice"
|
assert users_sec["users"][0]["username"] == "alice"
|
||||||
# Password hash never exposed
|
# Password hash never exposed
|
||||||
assert "password" not in users_sec["users"][0]
|
assert "password" not in users_sec["users"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-user filtering (owners/managers on the settings page)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from types import SimpleNamespace # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
MULTI_CFG = {
|
||||||
|
**CFG,
|
||||||
|
"users": {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
|
||||||
|
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
|
||||||
|
},
|
||||||
|
"notification_channels": {
|
||||||
|
"global_ch": {"type": "pushover", "token": "t", "user": "u"},
|
||||||
|
"bob_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "bob"},
|
||||||
|
"carol_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "carol"},
|
||||||
|
},
|
||||||
|
"threshold_configs": {
|
||||||
|
"bob_tc": {"owner": "bob", "thresholds": {}},
|
||||||
|
"carol_tc": {"owner": "carol", "thresholds": {}},
|
||||||
|
},
|
||||||
|
"hosts": {
|
||||||
|
"bobhost": {"owner": "bob"},
|
||||||
|
"managedhost": {"owner": "carol", "managers": ["bob"]},
|
||||||
|
"carolhost": {"owner": "carol"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BOB = SimpleNamespace(username="bob", admin=False)
|
||||||
|
ADMIN = SimpleNamespace(username="alice", admin=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_sees_only_three_sections():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
assert [s["id"] for s in sections] == ["channels", "hosts", "thresholds"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_sections_hide_admin_fields():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
for s in sections:
|
||||||
|
assert s["fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_hosts_filtered_with_is_owner():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
hosts = next(s for s in sections if s["id"] == "hosts")["hosts"]
|
||||||
|
by_name = {h["name"]: h for h in hosts}
|
||||||
|
assert set(by_name) == {"bobhost", "managedhost"}
|
||||||
|
assert by_name["bobhost"]["is_owner"] is True
|
||||||
|
assert by_name["managedhost"]["is_owner"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonadmin_channels_filtered_with_editable():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
|
||||||
|
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
|
||||||
|
assert set(chans) == {"global_ch", "bob_ch"}
|
||||||
|
assert chans["bob_ch"]["editable"] is True
|
||||||
|
assert chans["global_ch"]["editable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_everything_with_editable():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG, user=ADMIN)
|
||||||
|
ids = [s["id"] for s in sections]
|
||||||
|
assert "network" in ids and "users" in ids
|
||||||
|
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
|
||||||
|
assert set(chans) == {"global_ch", "bob_ch", "carol_ch"}
|
||||||
|
assert all(c["editable"] for c in chans.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_data_pickers_filtered_for_nonadmin():
|
||||||
|
data = settings_mod.get_settings_data(MULTI_CFG, user=BOB)
|
||||||
|
assert data["all_channel_names"] == ["bob_ch", "global_ch"]
|
||||||
|
assert data["all_threshold_configs"] == ["bob_tc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_user_means_admin_view():
|
||||||
|
sections = settings_mod.get_settings_sections(MULTI_CFG) # auth disabled
|
||||||
|
assert len(sections) > 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtering_unaffected_by_other_users_in_config():
|
||||||
|
"""The users-section loop must not clobber the requesting username (regression)."""
|
||||||
|
cfg = dict(MULTI_CFG)
|
||||||
|
cfg["users"] = {
|
||||||
|
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
|
||||||
|
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
|
||||||
|
"zed": {"full_name": "Zed", "admin": False, "password": "x"}, # bob is not last
|
||||||
|
}
|
||||||
|
sections = settings_mod.get_settings_sections(cfg, user=BOB)
|
||||||
|
hosts = {h["name"] for h in next(s for s in sections if s["id"] == "hosts")["hosts"]}
|
||||||
|
assert hosts == {"bobhost", "managedhost"}
|
||||||
|
|||||||
Reference in New Issue
Block a user