mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-28 11:14:43 +00:00
Add ProxMenux beta 1.2.1.3
This commit is contained in:
Binary file not shown.
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -351,6 +351,12 @@ export function NotificationSettings() {
|
|||||||
error: string
|
error: string
|
||||||
}>({ status: "idle", fallback_commands: [], error: "" })
|
}>({ status: "idle", fallback_commands: [], error: "" })
|
||||||
const [systemHostname, setSystemHostname] = useState<string>("")
|
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
|
// Load system hostname for display name placeholder
|
||||||
const loadSystemHostname = useCallback(async () => {
|
const loadSystemHostname = useCallback(async () => {
|
||||||
@@ -433,6 +439,43 @@ export function NotificationSettings() {
|
|||||||
loadSystemHostname()
|
loadSystemHostname()
|
||||||
}, [loadConfig, loadStatus, 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(() => {
|
useEffect(() => {
|
||||||
if (showHistory) loadHistory()
|
if (showHistory) loadHistory()
|
||||||
}, [showHistory, loadHistory])
|
}, [showHistory, loadHistory])
|
||||||
@@ -634,7 +677,16 @@ export function NotificationSettings() {
|
|||||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
||||||
const isEnabled = overrides.categories[cat.key] ?? true
|
const isEnabled = overrides.categories[cat.key] ?? true
|
||||||
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
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(
|
const enabledCount = eventsForGroup.filter(
|
||||||
e => (overrides.events?.[e.type] ?? e.default_enabled)
|
e => (overrides.events?.[e.type] ?? e.default_enabled)
|
||||||
).length
|
).length
|
||||||
@@ -1779,14 +1831,23 @@ export function NotificationSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<button
|
<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)}
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
>
|
>
|
||||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
{showAdvanced ? (
|
||||||
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
{config.ai_enabled && (
|
) : (
|
||||||
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
ON
|
)}
|
||||||
|
<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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 { 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 { NotificationSettings } from "./notification-settings"
|
||||||
import { HealthThresholds } from "./health-thresholds"
|
import { HealthThresholds } from "./health-thresholds"
|
||||||
|
import { LxcUpdateDetection } from "./lxc-update-detection"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { Switch } from "./ui/switch"
|
import { Switch } from "./ui/switch"
|
||||||
@@ -1194,6 +1195,12 @@ export function Settings() {
|
|||||||
values configured here drive what triggers the notifications below. */}
|
values configured here drive what triggers the notifications below. */}
|
||||||
<HealthThresholds />
|
<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 */}
|
{/* Notification Settings */}
|
||||||
<NotificationSettings />
|
<NotificationSettings />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.1.2-beta",
|
"version": "1.2.1.3-beta",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -10492,6 +10492,50 @@ def api_managed_installs_refresh():
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
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'])
|
@app.route('/api/health/thresholds', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_health_thresholds_get():
|
def api_health_thresholds_get():
|
||||||
@@ -11624,20 +11668,31 @@ if __name__ == '__main__':
|
|||||||
# Try gevent with SSL for proper WebSocket (WSS) support
|
# Try gevent with SSL for proper WebSocket (WSS) support
|
||||||
try:
|
try:
|
||||||
from gevent import pywsgi
|
from gevent import pywsgi
|
||||||
from geventwebsocket.handler import WebSocketHandler
|
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||||
|
|
||||||
print("[ProxMenux] Starting gevent server with SSL/WSS support...")
|
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
|
# `::` binds IPv6 + IPv4 (v4-mapped) on Linux when
|
||||||
# net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only
|
# net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only
|
||||||
# listening broke ProxMenux on dual-stack / v6-only hosts.
|
# listening broke ProxMenux on dual-stack / v6-only hosts.
|
||||||
server = pywsgi.WSGIServer(
|
server = pywsgi.WSGIServer(
|
||||||
('::', 8008),
|
('::', 8008),
|
||||||
app,
|
app,
|
||||||
handler_class=WebSocketHandler,
|
|
||||||
ssl_context=ssl_context
|
ssl_context=ssl_context
|
||||||
)
|
)
|
||||||
gevent_available = True
|
gevent_available = True
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -276,9 +277,14 @@ def _detect_oci_apps() -> list[dict]:
|
|||||||
# ── LXC containers (Phase 1: apt-based update detection) ────────────
|
# ── LXC containers (Phase 1: apt-based update detection) ────────────
|
||||||
#
|
#
|
||||||
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
|
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
|
||||||
# Detection is opt-in: gated on the `lxc_updates_available` notification
|
# Detection is gated on a dedicated user setting (`lxc_updates.detection_enabled`,
|
||||||
# being enabled somewhere, so the heavy `pct exec` work doesn't run on
|
# default ON) configured from Settings → LXC Update Detection. When the
|
||||||
# hosts where the user hasn't asked for this.
|
# 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
|
# Phase 2 hook: once helper-scripts metadata is integrated, entries can
|
||||||
# carry `_helper_script_app` so the checker swaps generic apt counting
|
# 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_EXEC_TIMEOUT_SEC = 10
|
||||||
_LXC_OS_PROBE_TIMEOUT_SEC = 5
|
_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:
|
def _lxc_updates_notification_enabled() -> bool:
|
||||||
"""Return True if the user has enabled `lxc_updates_available` on
|
"""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
|
family cached until the user resets the registry — acceptable
|
||||||
trade-off vs paying the probe cost every 24h cycle.
|
trade-off vs paying the probe cost every 24h cycle.
|
||||||
|
|
||||||
Detection runs unconditionally so the dashboard always reflects
|
Detection respects the dedicated `lxc_updates.detection_enabled`
|
||||||
pending updates on running CTs. The `lxc_updates_available`
|
toggle (Settings → LXC Update Detection). When OFF, this returns []
|
||||||
notification toggle only gates the *delivery* of the notification
|
and the framework's removed_at logic clears any pre-existing CT
|
||||||
(see _check_managed_installs_updates in notification_events.py),
|
rows from the registry on the next run — the explicit purge in
|
||||||
not the detection — that keeps the toggle semantics consistent with
|
``set_lxc_updates_detection_enabled`` handles the immediate case.
|
||||||
every other update stream (NVIDIA, Coral, post-install).
|
|
||||||
|
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`.
|
# Read existing registry so we can preserve cached `_os_family`.
|
||||||
# No lock needed here — we only inspect; the framework holds the
|
# No lock needed here — we only inspect; the framework holds the
|
||||||
# write lock when it merges back our results in detect_and_register.
|
# 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, ""
|
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:
|
def _check_lxc_updates(entry: dict) -> dict:
|
||||||
"""Inspect pending package updates inside the LXC and report them.
|
"""Inspect pending package updates inside the LXC and report them.
|
||||||
|
|
||||||
Dispatches to the right package-manager parser based on the cached
|
Dispatches to the right package-manager parser based on the cached
|
||||||
``_os_family``. Uses the CT's existing metadata cache — never runs
|
``_os_family``. If the CT's local apt/apk metadata cache is older
|
||||||
``apt update`` / ``apk update`` from outside, so the user's own
|
than 24h, runs a best-effort refresh first via
|
||||||
update cadence (unattended-upgrades, cron) is preserved.
|
``_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
|
The dedup fingerprint (``latest``) combines count, security count
|
||||||
and the sorted top package names so a stable set of pending
|
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",
|
"last_check": _now_iso(), "error": "no vmid in entry",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
|
||||||
|
|
||||||
if family in ("debian", "ubuntu"):
|
if family in ("debian", "ubuntu"):
|
||||||
ok, stdout, err = _run_pct_pkg_listing(
|
ok, stdout, err = _run_pct_pkg_listing(
|
||||||
vmid, "apt list --upgradable 2>/dev/null"
|
vmid, "apt list --upgradable 2>/dev/null"
|
||||||
@@ -920,6 +1127,9 @@ def _check_lxc_updates(entry: dict) -> dict:
|
|||||||
"_count": count,
|
"_count": count,
|
||||||
"_security_count": sec_count,
|
"_security_count": sec_count,
|
||||||
"_packages": packages[:30], # cap to keep the registry compact
|
"_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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.1.2
|
1.2.1.3
|
||||||
|
|||||||
Reference in New Issue
Block a user