mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user