New version 1.2.5

Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
This commit is contained in:
MacRimi
2026-09-01 19:15:42 +02:00
parent f8e65cc4c4
commit 315259f5ec
54 changed files with 4343 additions and 333 deletions
+1
View File
@@ -128,6 +128,7 @@ cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""User-defined web links surfaced in the Apps dashboard alongside
LXC-registered apps. Kept in a single sidecar
(/etc/proxmenux/custom_links.json) because the collection is small,
global, and never bound to a specific guest by ProxMenux itself.
Schema of each entry
--------------------
{
"id": "<uuid4>",
"name": "<display name>", # required
"url": "<http(s) URL>", # required
"logo_url": "<http(s) URL or ''>", # optional
"category": "<free text or ''>", # optional
"binding": { # optional; null when unbound
"vmid": <int>,
"guest_type": "lxc" | "qemu"
},
"created_at": <unix ts>,
"updated_at": <unix ts>
}
Design notes
------------
* One file (not per-VM). Volume is small; unbound links have no natural
home; global lookups are O(N) with N tiny.
* All writes go through `save_all` which does the classic
write-temp+rename dance so a crash mid-save can't corrupt the file.
* Validation is strict at the boundary — the frontend can send whatever;
the backend refuses anything malformed. Fields that survive are
exactly the schema above; unknown keys are dropped silently.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
from typing import Any, Optional
_CUSTOM_LINKS_PATH = "/etc/proxmenux/custom_links.json"
_lock = threading.RLock()
# In-memory copy of the full list. Populated on first read (or by
# `warmup()` at Monitor startup) and refreshed only when a write goes
# through this module. The sidecar file is our source of truth; the
# cache exists so `/api/apps/custom-links` doesn't hit disk on every
# request. Reads always return a fresh copy so callers can't mutate
# the cached state by accident.
_cached_entries: Optional[list[dict]] = None
# Same character set / max length as the LXC-app editor uses so users
# don't have to learn two different rulesets.
_NAME_RE = re.compile(r"^[\w\s._+\-()/:,&]{1,80}$", re.UNICODE)
_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
_GUEST_TYPES = frozenset({"lxc", "qemu"})
def _err(msg: str) -> tuple[bool, str]:
return False, msg
# ── Persistence ────────────────────────────────────────────────────
def _read_from_disk() -> list[dict]:
"""Actually parse the sidecar file. A missing/empty/corrupt file
returns [] — we never let bad JSON take down the whole Apps
dashboard, the user's other data is fine."""
try:
with open(_CUSTOM_LINKS_PATH, encoding="utf-8") as f:
raw = json.load(f)
except (FileNotFoundError, PermissionError):
return []
except (OSError, ValueError):
return []
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, dict)]
def load_all() -> list[dict]:
"""Return the current list of custom links from the in-memory
cache. First call after a Monitor restart pays one disk read
(~1 ms); every subsequent call is a memory op. Writes go through
`save_all` which also refreshes the cache, so callers never see
stale data.
"""
global _cached_entries
with _lock:
if _cached_entries is None:
_cached_entries = _read_from_disk()
return [dict(entry) for entry in _cached_entries]
def warmup() -> int:
"""Force the cache to populate now. Invoked from Monitor startup
so the very first `/api/apps/custom-links` request is served
straight from memory. Returns the entry count for the log line."""
global _cached_entries
with _lock:
_cached_entries = _read_from_disk()
return len(_cached_entries)
def save_all(entries: list[dict]) -> None:
"""Persist the full list. Write-temp+rename so a crash cannot
leave a half-written JSON on disk. Also refreshes the in-memory
cache so the next `load_all` returns the new state without a
disk read. Caller must have validated every entry — this
function trusts its input and writes verbatim.
"""
global _cached_entries
directory = os.path.dirname(_CUSTOM_LINKS_PATH)
os.makedirs(directory, exist_ok=True)
payload = json.dumps(entries, ensure_ascii=False, indent=2)
with _lock:
tmp = f"{_CUSTOM_LINKS_PATH}.tmp.{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(payload)
f.write("\n")
os.replace(tmp, _CUSTOM_LINKS_PATH)
_cached_entries = [dict(e) for e in entries]
finally:
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
# ── Validation ─────────────────────────────────────────────────────
def _validate_binding(raw: Any) -> tuple[bool, Any]:
"""Accepts either null (unbound) or {vmid, guest_type}. Coerces
vmid to int and guest_type to one of the allowed literals."""
if raw in (None, "", {}):
return True, None
if not isinstance(raw, dict):
return _err("binding must be an object with {vmid, guest_type}")
vmid_raw = raw.get("vmid")
try:
vmid = int(vmid_raw)
except (TypeError, ValueError):
return _err("binding.vmid must be an integer")
if not (0 < vmid <= 999_999_999):
return _err("binding.vmid out of range")
guest_type = (raw.get("guest_type") or "").strip().lower()
if guest_type not in _GUEST_TYPES:
return _err("binding.guest_type must be 'lxc' or 'qemu'")
return True, {"vmid": vmid, "guest_type": guest_type}
def validate_entry(raw: Any, existing_id: Optional[str] = None) -> tuple[bool, Any]:
"""Validate a single link payload from the API layer. Returns
(True, sanitised_dict) or (False, error_string). Fields absent in
the input default to safe values; unknown keys are ignored."""
if not isinstance(raw, dict):
return _err("payload must be a JSON object")
name = (raw.get("name") or "").strip()
if not name:
return _err("name is required")
if not _NAME_RE.match(name):
return _err("name contains invalid characters or exceeds 80 chars")
url = (raw.get("url") or "").strip()
if not url:
return _err("url is required")
if not _URL_RE.match(url):
return _err("url must be an http(s) URL (max 512 chars)")
logo_url = (raw.get("logo_url") or "").strip()
if logo_url and not _URL_RE.match(logo_url):
return _err("logo_url must be an http(s) URL (max 512 chars)")
category = (raw.get("category") or "").strip()
if category and not _CATEGORY_RE.match(category):
return _err("category contains invalid characters or exceeds 60 chars")
ok, binding = _validate_binding(raw.get("binding"))
if not ok:
return _err(binding)
entry_id = existing_id or raw.get("id") or str(uuid.uuid4())
if not _UUID_RE.match(entry_id):
entry_id = str(uuid.uuid4())
now = int(time.time())
return True, {
"id": entry_id,
"name": name,
"url": url,
"logo_url": logo_url,
"category": category,
"binding": binding,
"created_at": int(raw.get("created_at") or now),
"updated_at": now,
}
# ── CRUD helpers used by the Flask endpoints ───────────────────────
def create(payload: dict) -> tuple[bool, Any]:
"""Add a new link. Assigns a fresh UUID and appends to the file."""
ok, entry = validate_entry(payload)
if not ok:
return False, entry
with _lock:
current = load_all()
current.append(entry)
save_all(current)
return True, entry
def update(link_id: str, payload: dict) -> tuple[bool, Any]:
"""Replace one link by id. 404 if the id is unknown."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
for i, existing in enumerate(current):
if existing.get("id") == link_id:
merged = dict(existing)
merged.update(payload)
merged["id"] = link_id # id is immutable
merged["created_at"] = existing.get("created_at")
ok, entry = validate_entry(merged, existing_id=link_id)
if not ok:
return False, entry
current[i] = entry
save_all(current)
return True, entry
return _err("link not found")
def delete(link_id: str) -> tuple[bool, Any]:
"""Remove one link by id. Idempotent — deleting an unknown id
returns success so the UI doesn't have to distinguish."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
remaining = [e for e in current if e.get("id") != link_id]
if len(remaining) != len(current):
save_all(remaining)
return True, {"deleted": link_id}
def purge_binding_for_vmid(vmid: int) -> int:
"""Clear the `binding` on every link that pointed to a guest that
no longer exists. Called from the guest lifecycle hook when a VM
or CT is destroyed so the dashboard never surfaces a dead ID.
Returns the number of links updated (0 or more)."""
try:
target = int(vmid)
except (TypeError, ValueError):
return 0
changed = 0
with _lock:
current = load_all()
for entry in current:
binding = entry.get("binding") or {}
if isinstance(binding, dict) and binding.get("vmid") == target:
entry["binding"] = None
entry["updated_at"] = int(time.time())
changed += 1
if changed:
save_all(current)
return changed
+192
View File
@@ -609,6 +609,73 @@ def parse_lxc_hardware_config(vmid, node):
return hardware_info
def _get_lxc_primary_ip_cached(vmid):
"""Return the LXC's primary non-Docker IP with an indefinite
cache. First read per CT spawns one `lxc-info` subprocess;
subsequent reads are free until the CT's lifecycle event drops
the entry via `_invalidate_lxc_ip`. A running CT's IP doesn't
change on its own the invalidation on start/stop/reboot is the
only path that requires re-probing.
"""
try:
vmid_int = int(vmid)
except (TypeError, ValueError):
return None
if vmid_int in _lxc_ip_cache:
return _lxc_ip_cache[vmid_int]
info = get_lxc_ip_from_lxc_info(vmid_int)
ip = None
if info:
ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
_lxc_ip_cache[vmid_int] = ip
return ip
def _invalidate_lxc_ip(vmid):
"""Drop the cached IP so the next request re-probes `lxc-info`.
Fired from the guest lifecycle handler on start/stop/reboot."""
try:
_lxc_ip_cache.pop(int(vmid), None)
except (TypeError, ValueError):
pass
def _warmup_lxc_ip_cache() -> int:
"""Populate the LXC IP cache for every running CT at Monitor
startup. After this runs, /api/vms serves the IPs from memory
without spawning `lxc-info` on the request path the cache only
changes when a CT's lifecycle event (start/stop/reboot) fires the
invalidator. Returns the count for the startup log line.
"""
try:
result = subprocess.run(
['/usr/sbin/pct', 'list'],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return 0
if result.returncode != 0:
return 0
count = 0
for line in result.stdout.splitlines()[1:]:
parts = line.split()
if len(parts) < 2:
continue
try:
vmid_int = int(parts[0])
except ValueError:
continue
if parts[1].lower() != 'running':
continue
info = get_lxc_ip_from_lxc_info(vmid_int)
ip = None
if info:
ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
_lxc_ip_cache[vmid_int] = ip
count += 1
return count
def get_lxc_ip_from_lxc_info(vmid):
"""Get LXC IP addresses using lxc-info command (for DHCP containers)
Returns a dict with all IPs and classification"""
@@ -1634,6 +1701,11 @@ _vm_apps_cache: dict = {} # vmid -> (ts, payload)
_vm_app_suggestions_cache: dict = {} # vmid -> (ts, payload)
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
# LXC primary IP cache — populated on first read, held indefinitely.
# A running CT's IP doesn't change; the cache is invalidated only when
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
# polling is needed. See `_handle_guest_lifecycle`.
_lxc_ip_cache: dict = {}
# Effective TTL is "indefinite": these caches are refreshed only
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
# on start/stop/reboot, add/edit/delete app, apply update, edit
@@ -1942,6 +2014,11 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
_pvesh_cache['cluster_resources_vm_time'] = 0
_vm_cache_invalidate(guest_id)
_vm_disk_cache.pop(guest_id, None)
# LXC IP can only change when the CT restarts (fresh DHCP lease)
# or stops; drop the cached IP so the next /api/vms poll re-reads
# it via `lxc-info`. QEMU guests do not touch this cache.
if guest_type == 'lxc':
_invalidate_lxc_ip(guest_id)
if action in ('start', 'reboot'):
_schedule_started_guest_refresh(guest_id, guest_type)
return
@@ -6506,6 +6583,15 @@ def get_proxmox_vms():
app_list = lxc_app_map.get(str(resource.get('vmid')))
if app_list:
vm_data['app_watches'] = app_list
# Apps dashboard reads this to build
# weblinks. Only paid on CTs that have
# registered apps; the IP is cached
# indefinitely and invalidated by the
# guest lifecycle hook on start/stop/reboot.
if vm_type == 'lxc' and resource.get('status') == 'running':
_ip = _get_lxc_primary_ip_cached(resource.get('vmid'))
if _ip:
vm_data['ip'] = _ip
docker_inventory = lxc_docker_map.get(str(resource.get('vmid')))
# Docker image drift is an Updates-tab feature,
# not an automatic app detection. Do not attach
@@ -13678,6 +13764,92 @@ def api_lxc_apps_dockerhub_tag_preview():
return jsonify({'error': str(e)}), 500
# ── Custom Web Links ─────────────────────────────────────────────
# User-defined launcher entries (in the Apps dashboard) that don't
# come from a registered LXC app. Backed by /etc/proxmenux/custom_links.json
# — one small global sidecar. Full schema + validation lives in
# custom_links.py; the endpoints here are thin CRUD wrappers.
@app.route('/api/apps/custom-links', methods=['GET'])
@require_auth
def api_custom_links_list():
try:
import custom_links
return jsonify(custom_links.load_all())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links', methods=['POST'])
@require_auth
def api_custom_links_create():
payload = request.get_json(silent=True) or {}
try:
import custom_links
ok, result = custom_links.create(payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links/<link_id>', methods=['PUT'])
@require_auth
def api_custom_links_update(link_id):
payload = request.get_json(silent=True) or {}
try:
import custom_links
ok, result = custom_links.update(link_id, payload)
if not ok:
code = 404 if result == 'link not found' else 400
return jsonify({'error': result}), code
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/custom-links/<link_id>', methods=['DELETE'])
@require_auth
def api_custom_links_delete(link_id):
try:
import custom_links
ok, result = custom_links.delete(link_id)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/categories', methods=['GET'])
@require_auth
def api_apps_categories():
"""List of category preset labels the Web Link editor offers in
its Categoría dropdown. Sourced from helpers_cache.category_names
so the taxonomy stays aligned with community-scripts, with a
small built-in fallback so the dropdown never renders empty."""
try:
import lxc_apps
return jsonify(lxc_apps.get_category_presets())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/suggest_category', methods=['GET'])
@require_auth
def api_apps_suggest_category():
"""Auto-fill the Categoría field when the user types a Web Link
name that matches a helpers_cache entry (by slug or name). Returns
{"category": "<name>"} or {"category": null}."""
try:
import lxc_apps
name = (request.args.get('name') or '').strip()
return jsonify({'category': lxc_apps.suggest_category_for(name)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog/<slug>', methods=['GET'])
@require_auth
def api_apps_catalog_slug(slug):
@@ -21988,6 +22160,26 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Docker inventory startup init failed: {e}",
file=sys.stderr, flush=True)
# Warm the LXC IP cache once so the Apps dashboard renders
# instantly on first paint and /api/vms polls stay free of
# `lxc-info` subprocesses until a CT actually restarts.
try:
ip_count = _warmup_lxc_ip_cache()
print(f"[ProxMenux] LXC IP cache warmed ({ip_count} running CTs)",
flush=True)
except Exception as e:
print(f"[ProxMenux] LXC IP warmup failed: {e}",
file=sys.stderr, flush=True)
# Preload custom weblinks so the first Apps dashboard fetch
# is served straight from memory (0 disk I/O).
try:
import custom_links
cl_count = custom_links.warmup()
print(f"[ProxMenux] Custom links cache warmed ({cl_count} entries)",
flush=True)
except Exception as e:
print(f"[ProxMenux] Custom links warmup failed: {e}",
file=sys.stderr, flush=True)
threading.Thread(target=_deferred_startup_inits, daemon=True).start()
# Self-healing maintenance run on every startup. Two passes, both
+41 -18
View File
@@ -219,7 +219,12 @@ class HealthMonitor:
MEMORY_CRITICAL = 95
MEMORY_DURATION = 300 # 5 minutes sustained (aligned with CPU)
SWAP_WARNING_DURATION = 300
SWAP_CRITICAL_PERCENT = 5
# Swap CRITICAL now requires BOTH: swap file nearly full AND RAM
# genuinely tight. Alerting on just one of them fired constantly on
# healthy Proxmox hosts where the kernel proactively swaps out
# inactive pages while RAM remains plentifully available.
SWAP_HIGH_PERCENT = 80 # % of swap file in use
AVAILABLE_MIN_PERCENT = 15 # % of RAM that must stay available
SWAP_CRITICAL_DURATION = 120
# Storage Thresholds
@@ -435,7 +440,8 @@ class HealthMonitor:
(("cpu", "critical"), "CPU_CRITICAL"),
(("memory", "warning"), "MEMORY_WARNING"),
(("memory", "critical"), "MEMORY_CRITICAL"),
(("memory", "swap_critical"), "SWAP_CRITICAL_PERCENT"),
(("memory", "swap_high"), "SWAP_HIGH_PERCENT"),
(("memory", "available_min"), "AVAILABLE_MIN_PERCENT"),
(("host_storage", "warning"), "STORAGE_WARNING"),
(("host_storage", "critical"), "STORAGE_CRITICAL"),
(("cpu_temperature", "warning"), "TEMP_WARNING"),
@@ -625,12 +631,12 @@ class HealthMonitor:
current_time = time.time()
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
# Prune entries older than 10 minutes
@@ -1605,30 +1611,36 @@ class HealthMonitor:
def _check_memory_comprehensive(self) -> Dict[str, Any]:
"""
Check memory including RAM and swap with realistic thresholds.
Only alerts on truly problematic memory situations.
Swap CRITICAL requires the memory-pressure AND-clause: swap is
called out only when the swap file is nearly full AND RAM is
genuinely tight (available memory below the configured floor).
Alerting on swap size alone fires constantly on healthy hosts
where Linux proactively swaps out inactive pages the user
can't act on that signal and it drowns real pressure events.
"""
try:
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
current_time = time.time()
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
self.state_history[state_key] = [
entry for entry in self.state_history[state_key]
if current_time - entry['time'] < 600
]
mem_critical_samples = [
entry for entry in self.state_history[state_key]
if entry['mem_percent'] >= 90 and
@@ -1641,10 +1653,15 @@ class HealthMonitor:
current_time - entry['time'] <= self.MEMORY_DURATION
]
# Swap CRITICAL requires BOTH conditions sustained. Older
# samples predating the new `available_percent` field are
# skipped rather than defaulted to a passing value so the
# transition period never manufactures a false positive.
swap_critical = sum(
1 for entry in self.state_history[state_key]
if entry['swap_vs_ram'] > 20 and
current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
if entry['swap_percent'] > self.SWAP_HIGH_PERCENT
and entry.get('available_percent', 100) < self.AVAILABLE_MIN_PERCENT
and current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
)
# Require sustained high usage across most of the 300s window.
@@ -1663,7 +1680,8 @@ class HealthMonitor:
reason = f'RAM >90% sustained for {actual_duration}s'
elif swap_critical >= 2:
status = 'CRITICAL'
reason = f'Swap >20% of RAM ({swap_vs_ram:.1f}%)'
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
f'and only {available_percent:.0f}% RAM available')
elif mem_warning_count >= MEM_WARNING_MIN_SAMPLES:
oldest = min(s['time'] for s in mem_warning_samples)
actual_duration = int(current_time - oldest)
@@ -1672,12 +1690,12 @@ class HealthMonitor:
else:
status = 'OK'
reason = None
ram_avail_gb = round(memory.available / (1024**3), 2)
ram_total_gb = round(memory.total / (1024**3), 2)
swap_used_gb = round(swap.used / (1024**3), 2)
swap_total_gb = round(swap.total / (1024**3), 2)
# Determine per-sub-check status
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
@@ -1691,11 +1709,16 @@ class HealthMonitor:
'checks': {
'ram_usage': {
'status': ram_status,
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal'
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal',
'dismissable': True,
},
'swap_usage': {
'status': swap_status,
'detail': 'Excessive swap usage' if swap_status != 'OK' else ('Normal' if swap.total > 0 else 'No swap configured')
'detail': (
'Swap nearly full with RAM tight' if swap_status != 'OK'
else ('Normal' if swap.total > 0 else 'No swap configured')
),
'dismissable': True,
}
}
}
+6 -1
View File
@@ -65,7 +65,12 @@ DEFAULTS: dict[str, Any] = {
"memory": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
"swap_critical": {"value": 5, "unit": "%", "min": 1, "max": 100, "step": 1},
# Swap CRITICAL requires BOTH to hold: swap_high AND
# available_min. Alerting on swap alone was too noisy on
# Proxmox hosts where Linux proactively swaps inactive pages
# while RAM stays plentifully available.
"swap_high": {"value": 80, "unit": "%", "min": 1, "max": 100, "step": 1},
"available_min": {"value": 15, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"host_storage": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
+104
View File
@@ -262,6 +262,11 @@ _LOGO_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
# Community-scripts slug — lowercase letters/digits/dashes/underscores/dots.
# Same shape helpers_cache uses for its own slug field.
_HELPER_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
# Web Link category — free-text label the user picks from the presets
# built from helpers_cache.category_names, or types freely. Keep the
# charset permissive enough for community-scripts labels ("Media &
# Streaming", "*Arr Suite", "AI / Coding & Dev-Tools").
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
# OCI label key (e.g. org.opencontainers.image.version) — reverse-DNS
# style dot-separated identifiers.
_OCI_LABEL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._\-]{0,127}$")
@@ -463,6 +468,24 @@ def _validate_ports(ports_in: Any) -> tuple[bool, Any]:
if not _LOGO_URL_RE.match(link_logo):
return _err(f"ports[{i}].logo_url must be an http(s) URL (max 512 chars)")
entry["logo_url"] = link_logo
# Per-link category — optional free-text label the user picks
# from the presets sourced from helpers_cache.category_names.
# Powers the Apps dashboard (filter/group by category).
category = (item.get("category") or "").strip()
if category:
if not _CATEGORY_RE.match(category):
return _err(f"ports[{i}].category has invalid characters or is too long")
entry["category"] = category
# Optional custom URL — takes precedence over the ip:port
# composition when present. Used for apps reached through a
# reverse-proxy domain (e.g. https://vault.example.com) so the
# Apps dashboard opens the public URL instead of the internal
# ip:port. Same http(s) allow-list as the app-level logo.
custom_url = (item.get("custom_url") or "").strip()
if custom_url:
if not _LOGO_URL_RE.match(custom_url):
return _err(f"ports[{i}].custom_url must be an http(s) URL (max 512 chars)")
entry["custom_url"] = custom_url
out.append(entry)
return True, out
@@ -2135,6 +2158,13 @@ def _docker_inventory_from_ct(vmid) -> dict:
== image_id.removeprefix("sha256:")
)
})
if not used_by:
# Skip orphan images (no container — running or stopped —
# references them). They are residual `docker pull` artifacts
# that would report bogus "update available" entries for tags
# no live workload uses. The user manages orphan cleanup with
# `docker image prune` / `docker rmi` outside of ProxMenux.
continue
used_containers = [item for item in containers if item.get("name") in used_by]
compose_targets: dict[str, dict] = {}
standalone_containers: list[str] = []
@@ -3427,6 +3457,62 @@ def _summarise_app(app: dict) -> dict:
}
def get_category_presets() -> list:
"""Return the sorted list of unique category names sourced from
helpers_cache. Powers the "Categoría" dropdown in the Web Link
editor and the Apps dashboard filter. If the cache is missing or
empty, returns a short built-in fallback so the UI never shows an
empty preset list.
"""
fallback = [
"Adblock & DNS", "Authentication & Security", "Automation & Scheduling",
"Backup & Recovery", "Containers & Docker", "Databases",
"Documents & Notes", "Files & Downloads", "Media & Streaming",
"Miscellaneous", "Monitoring & Analytics", "Network & Firewall",
]
try:
import managed_installs
cache = managed_installs._fetch_helpers_cache() or {}
except Exception:
return fallback
seen: set = set()
for entry in cache.values():
if not isinstance(entry, dict):
continue
for name in entry.get("category_names") or []:
if isinstance(name, str) and name.strip():
seen.add(name.strip())
return sorted(seen) if seen else fallback
def suggest_category_for(name_or_slug: str) -> Optional[str]:
"""Look up a category preset by app name/slug against helpers_cache.
Powers the auto-fill in the Web Link editor — when the user types
a name that matches a catalog entry, the category dropdown
pre-selects the first category_names value. Returns None when the
name has no match or the cache is unavailable.
"""
if not name_or_slug:
return None
needle = name_or_slug.strip().lower()
if not needle:
return None
try:
import managed_installs
cache = managed_installs._fetch_helpers_cache() or {}
except Exception:
return None
for slug, entry in cache.items():
if not isinstance(entry, dict):
continue
if slug == needle or (entry.get("name") or "").lower() == needle:
cats = entry.get("category_names") or []
if cats and isinstance(cats[0], str) and cats[0].strip():
return cats[0].strip()
return None
return None
def get_catalog() -> list:
"""Return a compact catalog of registerable apps for the frontend
picker. Sourced from helpers_cache.json (community-scripts, ~700
@@ -3519,6 +3605,13 @@ def get_catalog_entry(slug: str, vmid=None) -> Optional[dict]:
except (TypeError, ValueError):
pass
# First category name from helpers_cache — auto-fills the port's
# Categoría field when the user picks this app from the catalog.
category = None
cat_names = catalog.get("category_names") or []
if cat_names and isinstance(cat_names[0], str) and cat_names[0].strip():
category = cat_names[0].strip()
return {
"slug": slug,
"name": catalog.get("name") or (hint.get("name") if isinstance(hint, dict) else None) or slug,
@@ -3528,6 +3621,7 @@ def get_catalog_entry(slug: str, vmid=None) -> Optional[dict]:
) or None,
"website": catalog.get("website") or "",
"default_ports": default_ports,
"category": category,
"tracking_suggestion": tracking,
}
@@ -4300,11 +4394,18 @@ def get_suggestions(vmid, force: bool = False) -> dict:
det_ports.append(n)
except (TypeError, ValueError):
pass
# Auto-fill Categoría preset from helpers_cache for extras too
# so the Register button pre-selects the category on the port.
det_category = None
det_cat_names = det_catalog.get("category_names") or []
if det_cat_names and isinstance(det_cat_names[0], str) and det_cat_names[0].strip():
det_category = det_cat_names[0].strip()
extras.append({
"slug": det_slug,
"name": det_name,
"logo_url": det_logo or None,
"default_ports": det_ports,
"category": det_category,
"tracking_suggestion": det_tracking,
})
@@ -4316,6 +4417,9 @@ def get_suggestions(vmid, force: bool = False) -> dict:
"tracking_suggestion": tracking,
"default_ports": default_ports,
"logo_url": logo_url or None,
# Categoría preset for the primary detection — same lookup as
# get_catalog_entry so the Register button pre-selects it.
"category": suggest_category_for(slug),
"extras": extras,
"docker_web_links": docker_web_links,
}
+5
View File
@@ -673,6 +673,11 @@ def _fetch_helpers_cache() -> dict:
"updateable": bool(entry.get("updateable")),
"default_port": entry.get("port") or 0,
"logo": entry.get("logo") or "",
# community-scripts taxonomy — powers the Categoría
# dropdown in the Web Link editor and the auto-fill
# on Registrar. Keep only the human-readable labels
# (ignore the parallel `categories` id list).
"category_names": entry.get("category_names") or [],
}
_helpers_cache = index
_helpers_cache_ts = now
+3 -1
View File
@@ -435,7 +435,9 @@ def is_apt_active_on_host() -> bool:
Sources checked, in order:
1. `/var/run/proxmenux-update-in-progress` — created by
`scripts/utilities/proxmox_update.sh` around its full-upgrade
call so ProxMenux-driven updates are always covered.
call, and by `scripts/post_install/update_post_install_function.sh`
around the per-tool re-run wrapper (log2ram, chrony…), so any
ProxMenux-driven maintenance is covered.
2. `fuser` on `/var/lib/dpkg/lock-frontend` — covers a manual
`apt`/`dpkg`/`apt-get` invocation by the operator, or any
other tool holding the lock.