mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-03 22:36:44 +00:00
update 1.2.4.1
This commit is contained in:
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4</p>
|
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4.1-beta</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -836,7 +836,7 @@ export function ProxmoxDashboard() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
|
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4.1-beta</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://ko-fi.com/macrimi"
|
href="https://ko-fi.com/macrimi"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
|||||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
|
||||||
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
|
const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json
|
||||||
|
|
||||||
interface ReleaseNote {
|
interface ReleaseNote {
|
||||||
date: string
|
date: string
|
||||||
|
|||||||
@@ -3717,7 +3717,7 @@ ${observationsHtml}
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="rpt-footer">
|
<div class="rpt-footer">
|
||||||
<div>Report generated by ProxMenux Monitor</div>
|
<div>Report generated by ProxMenux Monitor</div>
|
||||||
<div>ProxMenux Monitor v1.2.4</div>
|
<div>ProxMenux Monitor v1.2.4.1-beta</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.4",
|
"version": "1.2.4.1-beta",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1477,6 +1477,223 @@ _hardware_cache = {
|
|||||||
}
|
}
|
||||||
_HARDWARE_CACHE_TTL = 300 # 5 minutes - hardware doesn't change
|
_HARDWARE_CACHE_TTL = 300 # 5 minutes - hardware doesn't change
|
||||||
|
|
||||||
|
# VM guest-agent disk usage — refreshed by a daemon thread, read by
|
||||||
|
# /api/vms and by the health monitor. Keeping the subprocess OUT of the
|
||||||
|
# hot path is what makes this whole feature affordable: /api/vms is
|
||||||
|
# polled every few seconds by the dashboard, and blocking it on a
|
||||||
|
# per-VM `qm guest cmd` (200 ms – 3 s per VM depending on agent state)
|
||||||
|
# would silently regress a previously instant endpoint. The refresher
|
||||||
|
# absorbs that cost off-request and every reader gets a dict lookup.
|
||||||
|
#
|
||||||
|
# Cache stores the ALREADY-COMPUTED (used_bytes, total_bytes) tuple —
|
||||||
|
# not the raw fsinfo — so readers never parse or filter, and the
|
||||||
|
# expensive dedup / filesystem-type filtering runs at most once per
|
||||||
|
# refresh cycle per VM.
|
||||||
|
_vm_disk_cache = {} # vmid -> (fetched_at, (used, total) or None)
|
||||||
|
_VM_DISK_REFRESH_PERIOD = 60 # seconds between full refresh cycles
|
||||||
|
_VM_DISK_STALE_AFTER = 300 # readers ignore entries older than this
|
||||||
|
_VM_DISK_GA_TIMEOUT = 3 # per-VM `qm guest cmd` timeout (seconds)
|
||||||
|
_VM_DISK_REFRESH_WORKERS = 6 # parallelism cap for the fsinfo pass
|
||||||
|
|
||||||
|
# Filesystem types that never count towards VM disk usage:
|
||||||
|
# read-only image / CD formats, ram-backed pseudo-fs, kernel virtual
|
||||||
|
# filesystems, and union / container overlays. Anything not on this
|
||||||
|
# list is treated as real, persistent disk space.
|
||||||
|
_VM_FS_SKIP_TYPES = frozenset({
|
||||||
|
# Read-only image / CD formats
|
||||||
|
'erofs', 'squashfs', 'iso9660', 'udf', 'cdfs', 'romfs',
|
||||||
|
# Ram-backed
|
||||||
|
'tmpfs', 'devtmpfs', 'ramfs', 'zram',
|
||||||
|
# Kernel virtual
|
||||||
|
'proc', 'sysfs', 'cgroup', 'cgroup2', 'nsfs', 'fusectl',
|
||||||
|
'binfmt_misc', 'bpf', 'hugetlbfs', 'mqueue', 'pstore',
|
||||||
|
'tracefs', 'debugfs', 'securityfs', 'configfs', 'efivarfs',
|
||||||
|
'rpc_pipefs', 'selinuxfs',
|
||||||
|
# Overlay / union
|
||||||
|
'overlay', 'overlayfs', 'aufs', 'autofs',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_vm_disk_from_fsinfo(fsinfo):
|
||||||
|
"""Aggregate real filesystem usage from a QEMU guest agent fsinfo list.
|
||||||
|
|
||||||
|
Returns (used_bytes, total_bytes) when at least one persistent
|
||||||
|
filesystem is found, otherwise None. Filters non-persistent
|
||||||
|
filesystems and deduplicates bind-mounts by their backing block
|
||||||
|
device signature (bus, target, unit, dev) — a Home Assistant OS
|
||||||
|
layout with a dozen bind-mounts of /mnt/data is counted once,
|
||||||
|
Windows System-Reserved partitions of size 0 are dropped so C:\\
|
||||||
|
carries the value.
|
||||||
|
"""
|
||||||
|
if not fsinfo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
seen_disks = set()
|
||||||
|
total_used = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for fs in fsinfo:
|
||||||
|
if not isinstance(fs, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
fstype = (fs.get('type') or '').lower()
|
||||||
|
if not fstype or fstype in _VM_FS_SKIP_TYPES or fstype.startswith('fuse.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must be backed by a real block device — bind-mounts to pseudo
|
||||||
|
# filesystems have an empty disk[] and are already accounted
|
||||||
|
# for through the mount that owns the underlying device.
|
||||||
|
disks = fs.get('disk') or []
|
||||||
|
if not disks or not isinstance(disks[0], dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
d = disks[0]
|
||||||
|
sig = (d.get('bus'), d.get('target'), d.get('unit'), d.get('dev'))
|
||||||
|
if sig in seen_disks:
|
||||||
|
continue
|
||||||
|
seen_disks.add(sig)
|
||||||
|
|
||||||
|
try:
|
||||||
|
total = int(fs.get('total-bytes') or 0)
|
||||||
|
used = int(fs.get('used-bytes') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if total <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_size += total
|
||||||
|
total_used += used
|
||||||
|
|
||||||
|
if total_size <= 0:
|
||||||
|
return None
|
||||||
|
return total_used, total_size
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_vm_guest_fsinfo(vmid):
|
||||||
|
"""One-shot `qm guest cmd <vmid> get-fsinfo` — invoked only by the
|
||||||
|
background refresher, never on a request path. Returns the parsed
|
||||||
|
JSON list or None on any error (timeout, absent agent, malformed
|
||||||
|
response). Callers must NOT invoke this from an endpoint handler:
|
||||||
|
it blocks for up to _VM_DISK_GA_TIMEOUT seconds per call, which is
|
||||||
|
exactly what the refresher is designed to shield /api/vms from.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['qm', 'guest', 'cmd', str(vmid), 'get-fsinfo'],
|
||||||
|
capture_output=True, text=True, timeout=_VM_DISK_GA_TIMEOUT,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(result.stdout)
|
||||||
|
return parsed if isinstance(parsed, list) else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_vm_disk(vmid):
|
||||||
|
"""Read the pre-computed VM disk usage from cache — the ONLY function
|
||||||
|
request handlers should call. Returns (used, total) or None when
|
||||||
|
the VM isn't in the cache yet (fresh VM, refresher hasn't run yet)
|
||||||
|
or its data has aged past the staleness window. Never blocks and
|
||||||
|
never spawns subprocesses.
|
||||||
|
"""
|
||||||
|
entry = _vm_disk_cache.get(vmid)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
fetched_at, value = entry
|
||||||
|
if time.time() - fetched_at > _VM_DISK_STALE_AFTER:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_disk_refresh_one(vmid):
|
||||||
|
"""Refresh a single VMID's cache entry. Runs in a worker thread of
|
||||||
|
the refresher pool — never on the request path."""
|
||||||
|
fsinfo = _fetch_vm_guest_fsinfo(vmid)
|
||||||
|
computed = _compute_vm_disk_from_fsinfo(fsinfo)
|
||||||
|
_vm_disk_cache[vmid] = (time.time(), computed)
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_disk_refresher_loop():
|
||||||
|
"""Daemon-thread body that keeps `_vm_disk_cache` warm.
|
||||||
|
|
||||||
|
Every ``_VM_DISK_REFRESH_PERIOD`` seconds, walks the running QEMU
|
||||||
|
VMs from the (already-cached) cluster resources snapshot and fires
|
||||||
|
a parallel `qm guest cmd get-fsinfo` per VM through a small thread
|
||||||
|
pool. Absorbs every subprocess cost off-request so /api/vms is
|
||||||
|
always a dict lookup. Also drops entries for VMs that no longer
|
||||||
|
exist so the cache doesn't leak.
|
||||||
|
|
||||||
|
Robust to transient exceptions in the resources probe — a single
|
||||||
|
bad cycle just delays the next one, it never crashes the thread.
|
||||||
|
"""
|
||||||
|
# Small stagger so we don't compete with the request that started
|
||||||
|
# us for the very first cluster-resources fetch.
|
||||||
|
time.sleep(2)
|
||||||
|
while True:
|
||||||
|
cycle_started = time.time()
|
||||||
|
try:
|
||||||
|
resources = get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
live_vmids = set()
|
||||||
|
targets = []
|
||||||
|
for r in resources:
|
||||||
|
if r.get('type') not in ('qemu', 'vm'):
|
||||||
|
continue
|
||||||
|
if r.get('status') != 'running':
|
||||||
|
continue
|
||||||
|
vmid = r.get('vmid')
|
||||||
|
if vmid is None:
|
||||||
|
continue
|
||||||
|
live_vmids.add(vmid)
|
||||||
|
targets.append(vmid)
|
||||||
|
|
||||||
|
if targets:
|
||||||
|
# Bounded parallelism — fsinfo calls per VM are IO-bound
|
||||||
|
# (waiting on the guest agent), so a small pool wins big
|
||||||
|
# over serial execution but doesn't stampede the host.
|
||||||
|
try:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
with ThreadPoolExecutor(max_workers=_VM_DISK_REFRESH_WORKERS) as ex:
|
||||||
|
futures = [ex.submit(_vm_disk_refresh_one, v) for v in targets]
|
||||||
|
wait(futures, timeout=_VM_DISK_GA_TIMEOUT * 2)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to serial if the executor itself fails
|
||||||
|
for v in targets:
|
||||||
|
try:
|
||||||
|
_vm_disk_refresh_one(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Evict entries for VMs no longer running / removed. Iterate
|
||||||
|
# over a snapshot so the concurrent updates from the workers
|
||||||
|
# above (already finished at this point) don't fight us.
|
||||||
|
for v in list(_vm_disk_cache.keys()):
|
||||||
|
if v not in live_vmids:
|
||||||
|
_vm_disk_cache.pop(v, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sleep the remainder of the cycle — never less than 5s even if
|
||||||
|
# the refresh took longer, to avoid a busy loop when everything
|
||||||
|
# times out at once.
|
||||||
|
elapsed = time.time() - cycle_started
|
||||||
|
remaining = _VM_DISK_REFRESH_PERIOD - elapsed
|
||||||
|
time.sleep(max(remaining, 5.0))
|
||||||
|
|
||||||
|
|
||||||
|
_VM_DISK_REFRESHER_STARTED = False
|
||||||
|
def _ensure_vm_disk_refresher():
|
||||||
|
global _VM_DISK_REFRESHER_STARTED
|
||||||
|
if _VM_DISK_REFRESHER_STARTED:
|
||||||
|
return
|
||||||
|
_VM_DISK_REFRESHER_STARTED = True
|
||||||
|
import threading
|
||||||
|
t = threading.Thread(target=_vm_disk_refresher_loop, daemon=True, name='vm-disk-refresher')
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
def get_cached_pvesh_cluster_resources_vm():
|
def get_cached_pvesh_cluster_resources_vm():
|
||||||
"""Get cluster VM resources with a per-process cache.
|
"""Get cluster VM resources with a per-process cache.
|
||||||
@@ -4649,6 +4866,17 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Same pattern for the VM guest-agent fsinfo refresher — kicks off a
|
||||||
|
# daemon thread that keeps _vm_disk_cache warm so /api/vms and the
|
||||||
|
# health check only ever do dict lookups. First cycle runs ~2s after
|
||||||
|
# module load; running QEMU VMs report accurate disk usage as soon
|
||||||
|
# as their first refresh completes, and fall back to the PVE value
|
||||||
|
# in the meantime.
|
||||||
|
try:
|
||||||
|
_ensure_vm_disk_refresher()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ─── Per-NIC live rate (moving window) ──────────────────────────
|
# ─── Per-NIC live rate (moving window) ──────────────────────────
|
||||||
# Keep the last ~30s of byte counters and derive the rate from the
|
# Keep the last ~30s of byte counters and derive the rate from the
|
||||||
@@ -5447,6 +5675,20 @@ def get_proxmox_vms():
|
|||||||
upd = lxc_updates_map.get(str(resource.get('vmid')))
|
upd = lxc_updates_map.get(str(resource.get('vmid')))
|
||||||
if upd is not None:
|
if upd is not None:
|
||||||
vm_data['update_check'] = upd
|
vm_data['update_check'] = upd
|
||||||
|
|
||||||
|
# PVE's cluster resources API reports disk=0 for most
|
||||||
|
# QEMU VMs — it can't see inside the guest filesystem
|
||||||
|
# for the common storage backends. For running QEMU
|
||||||
|
# VMs we override with the guest-agent-derived value
|
||||||
|
# produced by the background refresher; readers only
|
||||||
|
# do a dict lookup, no subprocess ever runs on the
|
||||||
|
# request path. VMs not yet in the cache (fresh
|
||||||
|
# boot, refresher hasn't run) keep the PVE value.
|
||||||
|
if vm_type == 'qemu' and vm_data['status'] == 'running':
|
||||||
|
computed = get_cached_vm_disk(vm_data['vmid'])
|
||||||
|
if computed is not None:
|
||||||
|
vm_data['disk'], vm_data['maxdisk'] = computed
|
||||||
|
|
||||||
all_vms.append(vm_data)
|
all_vms.append(vm_data)
|
||||||
|
|
||||||
return all_vms
|
return all_vms
|
||||||
|
|||||||
@@ -964,6 +964,20 @@ class HealthMonitor:
|
|||||||
elif lxc_disk_result.get('status') == 'WARNING':
|
elif lxc_disk_result.get('status') == 'WARNING':
|
||||||
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
|
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
|
||||||
|
|
||||||
|
# QEMU VM filesystem usage via guest agent — mirrors the LXC
|
||||||
|
# rootfs check but reads from the guest agent because pvesh
|
||||||
|
# reports disk=0 for most QEMU storage backends. VMs without
|
||||||
|
# a responsive agent are silently skipped (no signal ≠ OK).
|
||||||
|
_t = time.time()
|
||||||
|
vm_disk_result = self._check_vm_disk_usage()
|
||||||
|
_perf_log("vm_disk_usage", (time.time() - _t) * 1000)
|
||||||
|
if vm_disk_result:
|
||||||
|
details['vm_disk'] = vm_disk_result
|
||||||
|
if vm_disk_result.get('status') == 'CRITICAL':
|
||||||
|
critical_issues.append(vm_disk_result.get('reason', 'VM filesystems near full'))
|
||||||
|
elif vm_disk_result.get('status') == 'WARNING':
|
||||||
|
warning_issues.append(vm_disk_result.get('reason', 'VM filesystems filling up'))
|
||||||
|
|
||||||
# Phase 3 capacity checks added on top of the existing storage
|
# Phase 3 capacity checks added on top of the existing storage
|
||||||
# ones. Each is independently configurable via Settings →
|
# ones. Each is independently configurable via Settings →
|
||||||
# Health Thresholds; defaults are 85/95 to align with the host
|
# Health Thresholds; defaults are 85/95 to align with the host
|
||||||
@@ -6262,6 +6276,149 @@ class HealthMonitor:
|
|||||||
'checks': checks,
|
'checks': checks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _check_vm_disk_usage(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""QEMU VM filesystem usage via the guest agent.
|
||||||
|
|
||||||
|
Sibling of ``_check_lxc_disk_usage`` that closes the analogous
|
||||||
|
gap for VMs: ``pvesh cluster resources`` reports ``disk=0`` for
|
||||||
|
most QEMU storage backends (PVE can't see inside the guest),
|
||||||
|
so this check asks the guest agent directly for every running
|
||||||
|
QEMU VM and emits WARNING at 85% / CRITICAL at 95% — same
|
||||||
|
defaults as the LXC counterpart. The aggregated total includes
|
||||||
|
every persistent filesystem the guest reports as backed by a
|
||||||
|
block device, PCI-passthrough drives included: the metric is
|
||||||
|
"how full is the guest", not "how full is the virtual disk
|
||||||
|
PVE knows about", which is intentionally more useful for
|
||||||
|
appliances like TrueNAS or a Synology VM.
|
||||||
|
|
||||||
|
VMs whose agent is absent, unreachable, times out, or reports
|
||||||
|
no usable data are skipped — no false OK, no false alert.
|
||||||
|
Reads the pre-computed cache maintained by the daemon refresher
|
||||||
|
in ``flask_server``; never spawns a subprocess on the check
|
||||||
|
path, so a slow / dead guest agent can't stretch the health
|
||||||
|
cycle.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import flask_server # deferred — avoids circular import
|
||||||
|
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[HealthMonitor] VM disk check failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Cheap short-circuit: no running QEMU VMs on this node.
|
||||||
|
if not any(
|
||||||
|
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
|
||||||
|
for r in resources
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
WARN_PCT, CRIT_PCT = self._read_capacity_thresholds('vm_disk', fb_warn=85, fb_crit=95)
|
||||||
|
|
||||||
|
checks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
critical_vms: list[str] = []
|
||||||
|
warning_vms: list[str] = []
|
||||||
|
emitted_keys: set[str] = set()
|
||||||
|
|
||||||
|
for r in resources:
|
||||||
|
if r.get('type') not in ('qemu', 'vm'):
|
||||||
|
continue
|
||||||
|
if r.get('status') != 'running':
|
||||||
|
continue
|
||||||
|
|
||||||
|
vmid = r.get('vmid')
|
||||||
|
if vmid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
computed = flask_server.get_cached_vm_disk(vmid)
|
||||||
|
except Exception:
|
||||||
|
computed = None
|
||||||
|
|
||||||
|
if computed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
used, total = computed
|
||||||
|
if total <= 0:
|
||||||
|
continue
|
||||||
|
pct = (used / total) * 100
|
||||||
|
vmid_str = str(vmid)
|
||||||
|
name = r.get('name', '') or ''
|
||||||
|
label = f'VM {vmid_str}' + (f' ({name})' if name else '')
|
||||||
|
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
'detail': f'guest filesystems {pct:.1f}% used ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
'usage_percent': round(pct, 1),
|
||||||
|
'disk_bytes': used,
|
||||||
|
'maxdisk_bytes': total,
|
||||||
|
'vmid': vmid_str,
|
||||||
|
'name': name,
|
||||||
|
}
|
||||||
|
error_key = f'vm_disk_{vmid_str}'
|
||||||
|
|
||||||
|
if pct >= CRIT_PCT:
|
||||||
|
entry['status'] = 'CRITICAL'
|
||||||
|
entry['error_key'] = error_key
|
||||||
|
entry['dismissable'] = True
|
||||||
|
checks[label] = entry
|
||||||
|
critical_vms.append(label)
|
||||||
|
emitted_keys.add(error_key)
|
||||||
|
health_persistence.record_error(
|
||||||
|
error_key=error_key,
|
||||||
|
category='storage',
|
||||||
|
severity='CRITICAL',
|
||||||
|
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
details=entry,
|
||||||
|
)
|
||||||
|
elif pct >= WARN_PCT:
|
||||||
|
entry['status'] = 'WARNING'
|
||||||
|
entry['error_key'] = error_key
|
||||||
|
entry['dismissable'] = True
|
||||||
|
checks[label] = entry
|
||||||
|
warning_vms.append(label)
|
||||||
|
emitted_keys.add(error_key)
|
||||||
|
health_persistence.record_error(
|
||||||
|
error_key=error_key,
|
||||||
|
category='storage',
|
||||||
|
severity='WARNING',
|
||||||
|
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
details=entry,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry['status'] = 'OK'
|
||||||
|
checks[label] = entry
|
||||||
|
|
||||||
|
# Clear stale VM disk errors (VM stopped, agent lost, freed up).
|
||||||
|
for err in (health_persistence.get_active_errors() or []):
|
||||||
|
ek = err.get('error_key', '')
|
||||||
|
if not ek.startswith('vm_disk_'):
|
||||||
|
continue
|
||||||
|
if ek not in emitted_keys:
|
||||||
|
health_persistence.clear_error(ek)
|
||||||
|
|
||||||
|
if not checks:
|
||||||
|
return None
|
||||||
|
if critical_vms:
|
||||||
|
entity, _ = _fmt_entity_and_summary(critical_vms, 'x', 'x')
|
||||||
|
return {
|
||||||
|
'status': 'CRITICAL',
|
||||||
|
'reason': f'{len(critical_vms)} VM(s) at >{CRIT_PCT}% filesystems: {_fmt_name_list(critical_vms)}',
|
||||||
|
'entity': entity,
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
if warning_vms:
|
||||||
|
entity, _ = _fmt_entity_and_summary(warning_vms, 'x', 'x')
|
||||||
|
return {
|
||||||
|
'status': 'WARNING',
|
||||||
|
'reason': f'{len(warning_vms)} VM(s) at >{WARN_PCT}% filesystems: {_fmt_name_list(warning_vms)}',
|
||||||
|
'entity': entity,
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'status': 'OK',
|
||||||
|
'reason': f'{len(checks)} running VM(s) within safe filesystem usage',
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
|
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Three sibling methods that all share the same shape:
|
# Three sibling methods that all share the same shape:
|
||||||
|
|||||||
@@ -924,6 +924,12 @@ class HealthPersistence:
|
|||||||
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
||||||
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
||||||
('security', 'security_'),
|
('security', 'security_'),
|
||||||
|
# `vm_disk_<vmid>` is a storage-category key that WOULD otherwise
|
||||||
|
# match the `vm_` prefix below and end up mis-tagged under `vms`;
|
||||||
|
# putting the storage-specific override first keeps the Dismiss
|
||||||
|
# flow honest (invalidates the storage cache, groups with the
|
||||||
|
# other capacity events).
|
||||||
|
('storage', 'vm_disk_'),
|
||||||
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
||||||
# ── Storage keys — HealthMonitor emits these under `storage` category
|
# ── Storage keys — HealthMonitor emits these under `storage` category
|
||||||
# but they used to fall through to 'general' here because no prefix
|
# but they used to fall through to 'general' here because no prefix
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
|
"vm_disk": {
|
||||||
|
# Aggregate guest-filesystem usage for running QEMU VMs, read
|
||||||
|
# from the guest agent (mirrors `lxc_rootfs` for VMs). Includes
|
||||||
|
# every persistent filesystem the guest reports on a block
|
||||||
|
# device, so PCI-passthrough drives and add-on storage count
|
||||||
|
# towards the threshold — the metric is "how full is the
|
||||||
|
# guest", not "how full is the disk PVE knows about".
|
||||||
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
"cpu_temperature": {
|
"cpu_temperature": {
|
||||||
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
|
|||||||
@@ -2772,6 +2772,8 @@ class PollingCollector:
|
|||||||
if category == 'storage':
|
if category == 'storage':
|
||||||
if error_key.startswith('lxc_disk_'):
|
if error_key.startswith('lxc_disk_'):
|
||||||
event_type = 'lxc_disk_low'
|
event_type = 'lxc_disk_low'
|
||||||
|
elif error_key.startswith('vm_disk_'):
|
||||||
|
event_type = 'vm_disk_low'
|
||||||
elif error_key.startswith('lxc_mount_'):
|
elif error_key.startswith('lxc_mount_'):
|
||||||
event_type = 'lxc_mount_low'
|
event_type = 'lxc_mount_low'
|
||||||
elif error_key.startswith('pve_storage_full_'):
|
elif error_key.startswith('pve_storage_full_'):
|
||||||
|
|||||||
@@ -1157,6 +1157,27 @@ TEMPLATES = {
|
|||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Aggregate filesystem usage reported by the QEMU guest agent for a
|
||||||
|
# running VM. Fires when the guest is filling up regardless of
|
||||||
|
# whether the storage is a virtual disk or a PCI-passthrough drive
|
||||||
|
# (TrueNAS-style appliances included) — the metric is "how full is
|
||||||
|
# the guest", not "how full is the disk PVE knows about".
|
||||||
|
'vm_disk_low': {
|
||||||
|
'title': '{hostname}: VM {vmid} filesystems at {usage_percent}%',
|
||||||
|
'body': (
|
||||||
|
'VM {vmid} ({name}) guest filesystems are at {usage_percent}% '
|
||||||
|
'({disk_bytes_human} / {maxdisk_bytes_human}).\n\n'
|
||||||
|
'Reported by the QEMU guest agent. Includes every persistent '
|
||||||
|
'filesystem the guest mounts on a block device — virtual disks '
|
||||||
|
'and PCI-passthrough drives alike. Free up space inside the '
|
||||||
|
'guest or expand the affected storage before writes start to '
|
||||||
|
'fail.'
|
||||||
|
),
|
||||||
|
'label': 'VM filesystems near full',
|
||||||
|
'group': 'storage',
|
||||||
|
'default_enabled': True,
|
||||||
|
},
|
||||||
|
|
||||||
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
|
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
|
||||||
# Three new events that complete the storage-monitoring picture.
|
# Three new events that complete the storage-monitoring picture.
|
||||||
# Each fires at the user-configured warning/critical thresholds
|
# Each fires at the user-configured warning/critical thresholds
|
||||||
@@ -1716,6 +1737,7 @@ EVENT_EMOJI = {
|
|||||||
'mount_stale': '\U0001F517', # link (broken connection feel)
|
'mount_stale': '\U0001F517', # link (broken connection feel)
|
||||||
'mount_readonly': '\U0001F512', # lock
|
'mount_readonly': '\U0001F512', # lock
|
||||||
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
|
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
|
||||||
|
'vm_disk_low': '\U0001F4BE', # floppy disk — same shape as LXC counterpart
|
||||||
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
|
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
|
||||||
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
|
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
|
||||||
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
|
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.4.0
|
1.2.4.1
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.4
|
1.2.4
|
||||||
|
|||||||
Reference in New Issue
Block a user