Add ProxMenux beta 1.2.1.3

This commit is contained in:
MacRimi
2026-05-22 18:24:03 +02:00
parent 95d0667077
commit 840385272c
9 changed files with 586 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
37819f92b22f4860908f3f4dbe26f071f5c971e903e36ac3cf6e5fcdd9b162a7 ProxMenux-1.2.1.2-beta.AppImage
d825487696ecdf071bf9aaed58f4bcc3e5b2e44e51770b746a85a359d1d71794
@@ -0,0 +1,227 @@
"use client"
import { useEffect, useState } from "react"
import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { fetchApi } from "../lib/api-config"
interface DetectionResponse {
success: boolean
enabled?: boolean
message?: string
purged?: number
}
export function LxcUpdateDetection() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState<boolean>(true)
const [pending, setPending] = useState<boolean>(true)
const [editMode, setEditMode] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [lastPurged, setLastPurged] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetchApi<DetectionResponse>("/api/lxc-updates/detection")
.then(data => {
if (cancelled) return
if (data.success && typeof data.enabled === "boolean") {
setEnabled(data.enabled)
setPending(data.enabled)
} else {
setError(data.message || "Failed to load setting")
}
})
.catch(e => {
if (!cancelled) setError(String(e))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const hasChanges = pending !== enabled
function handleEdit() {
setEditMode(true)
setError(null)
setSaved(false)
setLastPurged(null)
}
function handleCancel() {
setPending(enabled)
setEditMode(false)
setError(null)
setLastPurged(null)
}
async function handleSave() {
if (!hasChanges) {
setEditMode(false)
return
}
setSaving(true)
setError(null)
setSaved(false)
setLastPurged(null)
try {
const data = await fetchApi<DetectionResponse>("/api/lxc-updates/detection", {
method: "POST",
body: JSON.stringify({ enabled: pending }),
})
if (!data.success) {
setError(data.message || "Failed to save setting")
return
}
setEnabled(pending)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
if (!pending && typeof data.purged === "number" && data.purged > 0) {
setLastPurged(data.purged)
}
// Notify the Notifications section so it hides/shows the
// lxc_updates_available toggle in real time.
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("proxmenux:lxc-detection-changed", { detail: { enabled: pending } }),
)
}
} catch (e) {
setError(String(e))
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Boxes className="h-5 w-5 text-purple-500" />
<CardTitle>LXC Update Detection</CardTitle>
{enabled ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
Disabled
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
</span>
)}
{error && !editMode && (
<span
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={error}
>
Save failed: {error}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
Cancel
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={handleEdit}
disabled={loading}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
)}
</div>
</div>
<CardDescription>
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
corresponding notification toggle in <strong>Notifications Services</strong> appears only while detection
is enabled.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* ── Enable/Disable ── */}
<div className="flex items-center justify-between py-2 px-1">
<div className="flex items-center gap-2">
<Boxes
className={`h-4 w-4 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
/>
<div>
<span className="text-sm font-medium">Enable LXC update detection</span>
<p className="text-[11px] text-muted-foreground">
When OFF, ProxMenux stops scanning your CTs (no <code>pct exec</code> calls), removes existing LXC
entries from the managed-installs registry, and hides the related notification toggle. Default is
ON.
</p>
</div>
</div>
<button
className={`relative w-10 h-5 rounded-full transition-colors ${
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && setPending(p => !p)}
disabled={!editMode || saving}
role="switch"
aria-checked={pending}
aria-label="Enable LXC update detection"
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
pending ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{lastPurged !== null && lastPurged > 0 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
next scan cycle.
</p>
</div>
)}
{error && editMode && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-500 leading-relaxed break-all">{error}</p>
</div>
)}
</CardContent>
</Card>
)
}
+68 -7
View File
@@ -351,6 +351,12 @@ export function NotificationSettings() {
error: string
}>({ status: "idle", fallback_commands: [], error: "" })
const [systemHostname, setSystemHostname] = useState<string>("")
// Mirrors the dedicated toggle from Settings → LXC Update Detection.
// When false, the per-event toggle for `lxc_updates_available` is hidden
// from every channel's category list (its DB preference is preserved).
// Updated on mount via fetch and on the fly via a CustomEvent dispatched
// by <LxcUpdateDetection /> when the user flips the switch.
const [lxcDetectionEnabled, setLxcDetectionEnabled] = useState<boolean>(true)
// Load system hostname for display name placeholder
const loadSystemHostname = useCallback(async () => {
@@ -433,6 +439,43 @@ export function NotificationSettings() {
loadSystemHostname()
}, [loadConfig, loadStatus, loadSystemHostname])
// Track the LXC update-detection toggle so we can conditionally hide
// the `lxc_updates_available` per-event toggle inside every channel's
// category list. Fetched once on mount; live updates ride on a custom
// event dispatched by <LxcUpdateDetection /> whenever the user flips
// the switch upstream.
useEffect(() => {
let cancelled = false
fetchApi<{ success: boolean; enabled?: boolean }>("/api/lxc-updates/detection")
.then(data => {
if (cancelled) return
if (data.success && typeof data.enabled === "boolean") {
setLxcDetectionEnabled(data.enabled)
}
})
.catch(() => {
// Default-true on fetch failure — matches the backend default and
// avoids hiding a notification toggle the user might rely on if
// the settings endpoint is transiently unreachable.
})
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail
if (detail && typeof detail.enabled === "boolean") {
setLxcDetectionEnabled(detail.enabled)
}
}
if (typeof window !== "undefined") {
window.addEventListener("proxmenux:lxc-detection-changed", handler)
}
return () => {
cancelled = true
if (typeof window !== "undefined") {
window.removeEventListener("proxmenux:lxc-detection-changed", handler)
}
}
}, [])
useEffect(() => {
if (showHistory) loadHistory()
}, [showHistory, loadHistory])
@@ -634,7 +677,16 @@ export function NotificationSettings() {
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
const isEnabled = overrides.categories[cat.key] ?? true
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
const eventsForGroup = evtByGroup[cat.key] || []
// Hide the LXC update toggle when the user has disabled the
// dedicated detection setting upstream. The backend still
// returns the event type in the catalog (so its stored
// preference survives), but we filter it out of every
// channel's UI list so the operator never sees a notification
// toggle whose underlying scan is paused.
const rawEventsForGroup = evtByGroup[cat.key] || []
const eventsForGroup = lxcDetectionEnabled
? rawEventsForGroup
: rawEventsForGroup.filter(e => e.type !== "lxc_updates_available")
const enabledCount = eventsForGroup.filter(
e => (overrides.events?.[e.type] ?? e.default_enabled)
).length
@@ -1779,14 +1831,23 @@ export function NotificationSettings() {
<div>
<div className="flex items-center justify-between py-1">
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-2 text-sm text-foreground hover:bg-muted/60 rounded-md px-2 py-1.5 -mx-2 transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
{config.ai_enabled && (
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
ON
{showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<Sparkles className="h-4 w-4 text-purple-400" />
<span className="font-medium">AI Enhancement</span>
{config.ai_enabled ? (
<Badge variant="outline" className="text-[10px] border-purple-500/40 text-purple-400 ml-1">
Active
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-border text-muted-foreground ml-1">
Optional
</Badge>
)}
</button>
+7
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle } from "lucide-react"
import { NotificationSettings } from "./notification-settings"
import { HealthThresholds } from "./health-thresholds"
import { LxcUpdateDetection } from "./lxc-update-detection"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
@@ -1194,6 +1195,12 @@ export function Settings() {
values configured here drive what triggers the notifications below. */}
<HealthThresholds />
{/* LXC Update Detection gates the per-CT apt/apk scan. When OFF,
the matching toggle in NotificationSettings is hidden (the
preference is preserved in the DB and reappears when detection
is re-enabled). */}
<LxcUpdateDetection />
{/* Notification Settings */}
<NotificationSettings />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.1.2-beta",
"version": "1.2.1.3-beta",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
+59 -4
View File
@@ -10492,6 +10492,50 @@ def api_managed_installs_refresh():
return jsonify({'success': False, 'message': str(e)}), 500
# ─── LXC Update Detection toggle ────────────────────────────────────────────
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
# list --upgradable` scan entirely. The Notifications section keeps its own
# `lxc_updates_available` toggle (delivery only), but the UI hides it while
# detection is OFF — the underlying preference is preserved in the DB and
# re-appears when detection is flipped back ON.
@app.route('/api/lxc-updates/detection', methods=['GET'])
@require_auth
def api_lxc_updates_detection_get():
try:
import managed_installs
return jsonify({
'success': True,
'enabled': managed_installs._lxc_updates_detection_enabled(),
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/lxc-updates/detection', methods=['POST'])
@require_auth
def api_lxc_updates_detection_set():
try:
import managed_installs
data = request.get_json(silent=True) or {}
if 'enabled' not in data:
return jsonify({'success': False, 'message': 'Missing "enabled" field'}), 400
enabled = bool(data['enabled'])
result = managed_installs.set_lxc_updates_detection_enabled(enabled)
if not result.get('ok'):
return jsonify({
'success': False,
'message': result.get('error') or 'Failed to persist setting',
}), 500
return jsonify({
'success': True,
'enabled': enabled,
'purged': result.get('purged', 0),
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/health/thresholds', methods=['GET'])
@require_auth
def api_health_thresholds_get():
@@ -11624,20 +11668,31 @@ if __name__ == '__main__':
# Try gevent with SSL for proper WebSocket (WSS) support
try:
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
import ssl
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(ssl_cert, ssl_key)
print("[ProxMenux] Starting gevent server with SSL/WSS support...")
# IMPORTANT: do NOT pass `handler_class=WebSocketHandler`
# from geventwebsocket. flask-sock (the library wiring our
# /ws/terminal and /ws/script/<id> routes) already implements
# the WebSocket protocol on top of any standard WSGI server
# via `simple-websocket`. Stacking the geventwebsocket
# handler on top causes both layers to respond to the
# client's upgrade request — the server emits two
# `HTTP/1.1 101 Switching Protocols` headers back-to-back,
# which the browser interprets as a corrupt frame and
# closes with "WebSocket connection error" the moment the
# terminal modal opens. Using the default WSGIHandler lets
# flask-sock own the upgrade end-to-end.
#
# `::` binds IPv6 + IPv4 (v4-mapped) on Linux when
# net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only
# listening broke ProxMenux on dual-stack / v6-only hosts.
server = pywsgi.WSGIServer(
('::', 8008),
app,
handler_class=WebSocketHandler,
ssl_context=ssl_context
)
gevent_available = True
+222 -12
View File
@@ -40,6 +40,7 @@ import datetime
import json
import os
import re
import sqlite3
import subprocess
import threading
import time
@@ -276,9 +277,14 @@ def _detect_oci_apps() -> list[dict]:
# ── LXC containers (Phase 1: apt-based update detection) ────────────
#
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
# Detection is opt-in: gated on the `lxc_updates_available` notification
# being enabled somewhere, so the heavy `pct exec` work doesn't run on
# hosts where the user hasn't asked for this.
# Detection is gated on a dedicated user setting (`lxc_updates.detection_enabled`,
# default ON) configured from Settings → LXC Update Detection. When the
# user flips it OFF, this detector returns [] and any existing type="lxc"
# entries in the registry are purged so the dashboard / API immediately
# stop reporting LXC update state. The notification toggle
# (`lxc_updates_available`) keeps its independent semantics — it only
# decides whether to deliver the notification when detection has actually
# produced new results.
#
# Phase 2 hook: once helper-scripts metadata is integrated, entries can
# carry `_helper_script_app` so the checker swaps generic apt counting
@@ -289,6 +295,96 @@ _PCT_BIN = "/usr/sbin/pct"
_LXC_EXEC_TIMEOUT_SEC = 10
_LXC_OS_PROBE_TIMEOUT_SEC = 5
# User-toggle storage. The setting lives in the same SQLite DB that
# notification_manager uses for user_settings, so we get atomic writes
# and the table is already created at startup by health_persistence.
_USER_SETTINGS_DB = "/usr/local/share/proxmenux/health_monitor.db"
_LXC_DETECTION_SETTING_KEY = "lxc_updates.detection_enabled"
def _lxc_updates_detection_enabled() -> bool:
"""Read the dedicated detection toggle. Default True — existing
installs predating this setting keep their previous behaviour.
Read failures (DB missing, locked, corrupt) also default True so a
transient DB problem never silently disables the feature.
"""
try:
if not os.path.exists(_USER_SETTINGS_DB):
return True
conn = sqlite3.connect(_USER_SETTINGS_DB, timeout=5)
try:
conn.execute("PRAGMA busy_timeout=2000")
row = conn.execute(
"SELECT setting_value FROM user_settings WHERE setting_key = ?",
(_LXC_DETECTION_SETTING_KEY,),
).fetchone()
finally:
conn.close()
if row is None or row[0] is None:
return True
return str(row[0]).strip().lower() in ("1", "true", "yes", "on")
except Exception:
return True
def set_lxc_updates_detection_enabled(enabled: bool) -> dict:
"""Persist the toggle. Returns ``{ok: bool, purged: int, error?: str}``.
On OFF, also strip every ``type=lxc`` entry from the registry so the
dashboard and ``/api/managed-installs`` stop returning stale results
instantly without waiting for the next 24h detection cycle.
"""
val = "true" if enabled else "false"
try:
conn = sqlite3.connect(_USER_SETTINGS_DB, timeout=10)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(
"INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at) "
"VALUES (?, ?, ?)",
(_LXC_DETECTION_SETTING_KEY, val, _now_iso()),
)
conn.commit()
finally:
conn.close()
except Exception as e:
return {"ok": False, "purged": 0, "error": str(e)}
purged = 0
if not enabled:
purged = _purge_lxc_entries_from_registry()
return {"ok": True, "purged": purged}
def _purge_lxc_entries_from_registry() -> int:
"""Remove every type="lxc" entry from the registry. Returns the
count of entries removed.
Used when the user disables LXC update detection keeps the
on-disk state consistent with the toggle (zero stale LXC rows in
``managed_installs.json``).
"""
try:
with _lock:
reg = _read_registry()
items = reg.get("items", [])
if not items:
return 0
kept = [
it for it in items
if not (isinstance(it, dict) and it.get("type") == "lxc")
]
removed = len(items) - len(kept)
if removed > 0:
reg["items"] = kept
_write_registry(reg)
return removed
except Exception as e:
print(f"[managed_installs] failed to purge LXC entries: {e}")
return 0
def _lxc_updates_notification_enabled() -> bool:
"""Return True if the user has enabled `lxc_updates_available` on
@@ -382,13 +478,19 @@ def _detect_lxc_containers() -> list[dict]:
family cached until the user resets the registry acceptable
trade-off vs paying the probe cost every 24h cycle.
Detection runs unconditionally so the dashboard always reflects
pending updates on running CTs. The `lxc_updates_available`
notification toggle only gates the *delivery* of the notification
(see _check_managed_installs_updates in notification_events.py),
not the detection that keeps the toggle semantics consistent with
every other update stream (NVIDIA, Coral, post-install).
Detection respects the dedicated `lxc_updates.detection_enabled`
toggle (Settings LXC Update Detection). When OFF, this returns []
and the framework's removed_at logic clears any pre-existing CT
rows from the registry on the next run the explicit purge in
``set_lxc_updates_detection_enabled`` handles the immediate case.
The notification toggle (`lxc_updates_available`) only gates the
*delivery* of the notification (see _check_managed_installs_updates
in notification_events.py), independently of this detection toggle.
"""
if not _lxc_updates_detection_enabled():
return []
# Read existing registry so we can preserve cached `_os_family`.
# No lock needed here — we only inspect; the framework holds the
# write lock when it merges back our results in detect_and_register.
@@ -860,13 +962,116 @@ def _run_pct_pkg_listing(vmid: str, cmd: str) -> tuple[bool, str, str]:
return True, r.stdout, ""
# Refresh thresholds for the package-manager metadata cache. Threshold is
# 24h to match the rest of the check cycle: if a CT was last refreshed
# longer ago than that, we assume `apt list --upgradable` cannot reflect
# the upstream state and proactively refresh once before listing.
_LXC_CACHE_STALE_THRESHOLD_SEC = 24 * 3600
_LXC_CACHE_REFRESH_TIMEOUT_SEC = 60
def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict:
"""Best-effort refresh of the CT's package-manager metadata cache.
If the local cache is older than ``_LXC_CACHE_STALE_THRESHOLD_SEC``,
run ``apt-get update`` / ``apk update`` from outside the CT once
before the upgradable listing. Any failure (no network, broken
repo, timeout) is swallowed silently the listing below still
runs against whatever cache exists, so the detector can never make
the situation worse than the pre-existing CT state.
Returns a small diagnostics dict consumed by ``_check_lxc_updates``
to populate ``_cache_age_seconds`` / ``_cache_refreshed`` on the
registry entry (visible in the dashboard / managed-installs API).
"""
if family in ("debian", "ubuntu"):
# apt's authoritative timestamp is the mtime of pkgcache.bin,
# which `apt-get update` rewrites on every successful run.
# We `printf %Y` to get the mtime as a unix timestamp and `||
# echo 0` so a missing file (fresh CT, broken state) is treated
# as infinitely old and triggers the refresh.
cmd_age = "stat -c '%Y' /var/cache/apt/pkgcache.bin 2>/dev/null || echo 0"
cmd_refresh = "apt-get update -qq"
elif family == "alpine":
# apk writes index files under /var/lib/apk/. The
# `installed` file timestamp moves on package installs, but
# `apk update` rewrites the cached APKINDEX bundles under
# /var/cache/apk/*.tar.gz — take the newest mtime there as
# the authoritative "last update" marker. If the cache dir
# doesn't exist (apk default with caching disabled), fall
# back to the index files in /etc/apk/.
cmd_age = (
"ls -t /var/cache/apk/*.tar.gz 2>/dev/null | head -1 "
"| xargs -r stat -c '%Y' 2>/dev/null "
"|| stat -c '%Y' /etc/apk/world 2>/dev/null || echo 0"
)
cmd_refresh = "apk update"
else:
return {"refreshed": False, "was_stale": False, "cache_age_seconds": None, "error": None}
ok, stdout, _ = _run_pct_pkg_listing(vmid, cmd_age)
if not ok:
return {"refreshed": False, "was_stale": False, "cache_age_seconds": None, "error": "stat failed"}
try:
# Use the last numeric line in case the command emitted stderr
# noise that snuck into stdout (e.g. some shells route warnings).
cache_mtime = 0
for ln in stdout.strip().splitlines():
try:
cache_mtime = int(ln.strip())
break
except ValueError:
continue
except Exception:
cache_mtime = 0
now = int(time.time())
cache_age = (now - cache_mtime) if cache_mtime > 0 else None
was_stale = cache_age is None or cache_age > _LXC_CACHE_STALE_THRESHOLD_SEC
if not was_stale:
return {
"refreshed": False, "was_stale": False,
"cache_age_seconds": cache_age, "error": None,
}
try:
r = subprocess.run(
[_PCT_BIN, "exec", vmid, "--", "sh", "-c", cmd_refresh],
capture_output=True, text=True,
timeout=_LXC_CACHE_REFRESH_TIMEOUT_SEC,
)
if r.returncode == 0:
return {
"refreshed": True, "was_stale": True,
"cache_age_seconds": cache_age, "error": None,
}
return {
"refreshed": False, "was_stale": True,
"cache_age_seconds": cache_age,
"error": (r.stderr or "refresh failed").strip()[:200],
}
except subprocess.TimeoutExpired:
return {
"refreshed": False, "was_stale": True,
"cache_age_seconds": cache_age, "error": "refresh timed out",
}
except (FileNotFoundError, OSError) as e:
return {
"refreshed": False, "was_stale": True,
"cache_age_seconds": cache_age, "error": str(e),
}
def _check_lxc_updates(entry: dict) -> dict:
"""Inspect pending package updates inside the LXC and report them.
Dispatches to the right package-manager parser based on the cached
``_os_family``. Uses the CT's existing metadata cache — never runs
``apt update`` / ``apk update`` from outside, so the user's own
update cadence (unattended-upgrades, cron) is preserved.
``_os_family``. If the CT's local apt/apk metadata cache is older
than 24h, runs a best-effort refresh first via
``_refresh_lxc_pkg_cache_if_stale`` without this, CTs that no
one ever runs ``apt update`` in (long-running appliances) report
0 pending updates even when upstream has hundreds queued.
The dedup fingerprint (``latest``) combines count, security count
and the sorted top package names so a stable set of pending
@@ -881,6 +1086,8 @@ def _check_lxc_updates(entry: dict) -> dict:
"last_check": _now_iso(), "error": "no vmid in entry",
}
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
if family in ("debian", "ubuntu"):
ok, stdout, err = _run_pct_pkg_listing(
vmid, "apt list --upgradable 2>/dev/null"
@@ -920,6 +1127,9 @@ def _check_lxc_updates(entry: dict) -> dict:
"_count": count,
"_security_count": sec_count,
"_packages": packages[:30], # cap to keep the registry compact
"_cache_age_seconds": refresh_diag.get("cache_age_seconds"),
"_cache_refreshed": refresh_diag.get("refreshed"),
"_cache_refresh_error": refresh_diag.get("error"),
}