update 1.2.4.1

This commit is contained in:
MacRimi
2026-07-29 00:15:39 +02:00
parent ebd41b8a46
commit 37189ed67e
13 changed files with 446 additions and 7 deletions
+1 -1
View File
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
</form>
</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>
)
+1 -1
View File
@@ -836,7 +836,7 @@ export function ProxmoxDashboard() {
</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">
<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>
<a
href="https://ko-fi.com/macrimi"
+1 -1
View File
@@ -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 { 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 {
date: string
+1 -1
View File
@@ -3717,7 +3717,7 @@ ${observationsHtml}
<!-- Footer -->
<div class="rpt-footer">
<div>Report generated by ProxMenux Monitor</div>
<div>ProxMenux Monitor v1.2.4</div>
<div>ProxMenux Monitor v1.2.4.1-beta</div>
</div>
</body>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.4",
"version": "1.2.4.1-beta",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
+242
View File
@@ -1477,6 +1477,223 @@ _hardware_cache = {
}
_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():
"""Get cluster VM resources with a per-process cache.
@@ -4649,6 +4866,17 @@ try:
except Exception:
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) ──────────────────────────
# 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')))
if upd is not None:
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)
return all_vms
+157
View File
@@ -964,6 +964,20 @@ class HealthMonitor:
elif lxc_disk_result.get('status') == 'WARNING':
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
# ones. Each is independently configurable via Settings →
# Health Thresholds; defaults are 85/95 to align with the host
@@ -6262,6 +6276,149 @@ class HealthMonitor:
'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 ─────────────────────────────────────────────
#
# Three sibling methods that all share the same shape:
+6
View File
@@ -924,6 +924,12 @@ class HealthPersistence:
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
('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_'),
# ── Storage keys — HealthMonitor emits these under `storage` category
# but they used to fall through to 'general' here because no prefix
+10
View File
@@ -75,6 +75,16 @@ DEFAULTS: dict[str, Any] = {
"warning": {"value": 85, "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": {
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
+2
View File
@@ -2772,6 +2772,8 @@ class PollingCollector:
if category == 'storage':
if error_key.startswith('lxc_disk_'):
event_type = 'lxc_disk_low'
elif error_key.startswith('vm_disk_'):
event_type = 'vm_disk_low'
elif error_key.startswith('lxc_mount_'):
event_type = 'lxc_mount_low'
elif error_key.startswith('pve_storage_full_'):
@@ -1157,6 +1157,27 @@ TEMPLATES = {
'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) ─────────────────────────
# Three new events that complete the storage-monitoring picture.
# Each fires at the user-configured warning/critical thresholds
@@ -1716,6 +1737,7 @@ EVENT_EMOJI = {
'mount_stale': '\U0001F517', # link (broken connection feel)
'mount_readonly': '\U0001F512', # lock
'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
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
+1 -1
View File
@@ -1 +1 @@
1.2.4.0
1.2.4.1
+1 -1
View File
@@ -1 +1 @@
1.2.4
1.2.4