mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
fix: improve host diagnostics, storage handling, and maintenance workflows
- add zero-downtime Proxmox TLS certificate refresh from the Security panel (#307) - classify storage availability independently from missing capacity information (#309) - update ZFS ARC sizing and safely reconcile conflicting module configurations - preserve and restore migrated ZFS settings without overwriting later administrator changes - stop memory optimization from forcing the kernel overcommit policy - correlate multi-line OOM events and identify the affected LXC, cgroup limits, swap and killed process - add selectable Bash prompt path styles and clearer shell activation guidance - protect technical names during automatic translation and correct localized terminology - update the Coral and VM/LXC Apps and Updates documentation, translations and screenshots
This commit is contained in:
@@ -270,6 +270,7 @@ export function Security() {
|
||||
const [proxmoxCertInfo, setProxmoxCertInfo] = useState<{subject?: string; expires?: string; issuer?: string; is_self_signed?: boolean} | null>(null)
|
||||
const [loadingSsl, setLoadingSsl] = useState(true)
|
||||
const [configuringSsl, setConfiguringSsl] = useState(false)
|
||||
const [reloadingSsl, setReloadingSsl] = useState(false)
|
||||
const [sslRestarting, setSslRestarting] = useState(false)
|
||||
const [showCustomCertForm, setShowCustomCertForm] = useState(false)
|
||||
const [customCertPath, setCustomCertPath] = useState("")
|
||||
@@ -1779,6 +1780,35 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
}
|
||||
}
|
||||
|
||||
const handleReloadSsl = async () => {
|
||||
setReloadingSsl(true)
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
try {
|
||||
const data = await fetchApi("/api/ssl/reload", {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
setSslCertPath(data.cert_path || sslCertPath)
|
||||
setSslKeyPath(data.key_path || sslKeyPath)
|
||||
if (data.cert_info) {
|
||||
setProxmoxCertInfo(data.cert_info)
|
||||
}
|
||||
setSuccess(data.changed
|
||||
? st("messages.sslCertificateReloaded")
|
||||
: st("messages.sslCertificateUnchanged"))
|
||||
} else {
|
||||
setError(data.message || st("errors.reloadSslFailed"))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : st("errors.reloadSslFailed"))
|
||||
} finally {
|
||||
setReloadingSsl(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -2154,15 +2184,26 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
<p><span className="font-medium text-foreground">{st("ssl.cert")}:</span> <code className="text-xs">{sslCertPath}</code></p>
|
||||
<p><span className="font-medium text-foreground">{st("ssl.key")}:</span> <code className="text-xs">{sslKeyPath}</code></p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDisableSsl}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={configuringSsl || sslRestarting}
|
||||
className="mt-2 text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
|
||||
>
|
||||
{configuringSsl ? st("ssl.disabling") : sslRestarting ? st("ssl.restarting") : st("ssl.disableHttps")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleReloadSsl}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={configuringSsl || reloadingSsl || sslRestarting}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${reloadingSsl ? "animate-spin" : ""}`} />
|
||||
{reloadingSsl ? st("ssl.updatingCertificate") : st("ssl.updateCertificate")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisableSsl}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={configuringSsl || reloadingSsl || sslRestarting}
|
||||
className="text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
|
||||
>
|
||||
{configuringSsl ? st("ssl.disabling") : sslRestarting ? st("ssl.restarting") : st("ssl.disableHttps")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ interface RemoteStorage {
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
capacity_known?: boolean
|
||||
exclude_health: boolean
|
||||
exclude_notifications: boolean
|
||||
excluded_at?: string
|
||||
@@ -1509,7 +1510,8 @@ export function Settings() {
|
||||
const isExcluded = storage.exclude_health || storage.exclude_notifications
|
||||
const isSaving = savingStorage === storage.name
|
||||
const isNamespaceRestricted = storage.status === 'namespace_restricted'
|
||||
const isOffline = !isNamespaceRestricted && (storage.status === 'error' || storage.total === 0)
|
||||
const isOffline = !isNamespaceRestricted && storage.status !== 'active'
|
||||
const capacityKnown = storage.capacity_known ?? storage.total > 0
|
||||
|
||||
return (
|
||||
<div key={storage.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
|
||||
@@ -1530,6 +1532,9 @@ export function Settings() {
|
||||
{isNamespaceRestricted && (
|
||||
<p className="text-[11px] text-blue-400 mt-0.5">{t("settings.remoteStorage.namespaceRestricted")}</p>
|
||||
)}
|
||||
{!isOffline && !isNamespaceRestricted && !capacityKnown && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{t("storage.capacityNotReported")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ interface ProxmoxStorage {
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
capacity_known?: boolean
|
||||
node: string // Added node property for detailed debug logging
|
||||
}
|
||||
|
||||
@@ -1346,6 +1347,7 @@ export function StorageOverview() {
|
||||
// Check if storage is excluded from monitoring
|
||||
const isExcluded = storage.excluded === true
|
||||
const hasError = storage.status === "error" && !isExcluded
|
||||
const capacityKnown = storage.capacity_known ?? storage.total > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1425,46 +1427,50 @@ export function StorageOverview() {
|
||||
? t("storage.notMonitored")
|
||||
: storageStatusLabel(storage.status)}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{storage.percent}%</span>
|
||||
{capacityKnown && <span className="text-sm font-medium">{storage.percent}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={storage.percent}
|
||||
className={`h-2 ${
|
||||
storage.percent > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: storage.percent > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{formatStorage(storage.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
storage.percent > 90
|
||||
? "text-red-400"
|
||||
: storage.percent > 75
|
||||
? "text-yellow-400"
|
||||
: "text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{formatStorage(storage.used)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
|
||||
{capacityKnown ? (
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={storage.percent}
|
||||
className={`h-2 ${
|
||||
storage.percent > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: storage.percent > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{formatStorage(storage.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
storage.percent > 90
|
||||
? "text-red-400"
|
||||
: storage.percent > 75
|
||||
? "text-yellow-400"
|
||||
: "text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{formatStorage(storage.used)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("storage.capacityNotReported")}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -5296,10 +5296,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t("vmLxc.updates.dockerImagesReadOnly")}
|
||||
{selectedVM.docker_inventory.checked_at
|
||||
? ` · ${t("vmLxc.updates.lastCheckedPrefix")} ${new Date(selectedVM.docker_inventory.checked_at).toLocaleString()}`
|
||||
: ""}
|
||||
{t("vmLxc.updates.lastCheckedPrefix")} {selectedVM.docker_inventory.checked_at
|
||||
? new Date(selectedVM.docker_inventory.checked_at).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
{dockerInventoryRefreshing ? (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "nicht überwacht",
|
||||
"namespaceRestricted": "Namespace-beschränkt",
|
||||
"namespaceRestrictedTitle": "Speicher erreichbar; Durch ACL ausgeblendete Datenspeichergröße (z. B. PBS DatastoreAdmin in einem einzelnen Namespace)",
|
||||
"capacityNotReported": "Kapazität nicht gemeldet",
|
||||
"managedByProxmox": "verwaltet von Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "abgestanden",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Docker-Images",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Images",
|
||||
"dockerImagesReadOnly": "Image-Tags werden anhand des Registry-Digests verglichen. Compose-Dienste können aus ihrem deklarierten Projekt geladen und neu erstellt werden.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} wird über Pakete verwaltet und durch die obige Betriebssystem-Paketaktion aktualisiert.",
|
||||
"dockerEngineSubheading": "Engine",
|
||||
"dockerEngineDetected": "Docker Engine {version} erkannt",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "Vom Server wurde kein Token zurückgegeben.",
|
||||
"generateTokenRetry": "Token konnte nicht generiert werden. Bitte versuchen Sie es erneut.",
|
||||
"configureSslFailed": "SSL konnte nicht konfiguriert werden",
|
||||
"reloadSslFailed": "Das aktive Zertifikat konnte nicht aktualisiert werden",
|
||||
"disableSslFailed": "SSL konnte nicht deaktiviert werden",
|
||||
"deleteReportFailed": "Der Bericht konnte nicht gelöscht werden"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "API-Token wurde generiert.",
|
||||
"sslEnabledRestarting": "HTTPS ist aktiviert. Dienst wird neu gestartet...",
|
||||
"sslDisabledRestarting": "HTTPS ist deaktiviert. Dienst wird neu gestartet...",
|
||||
"sslCertificateReloaded": "Das erneuerte Zertifikat ist jetzt aktiv. Bestehende Verbindungen wurden nicht unterbrochen.",
|
||||
"sslCertificateUnchanged": "Das aktive Zertifikat entspricht bereits den aktuellen Zertifikatsdateien.",
|
||||
"reportDeleted": "Auditbericht wurde gelöscht.",
|
||||
"twoFactorEnabled": "Die Zwei-Faktor-Authentifizierung ist aktiviert."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Aktives Zertifikat",
|
||||
"cert": "Zertifikat",
|
||||
"key": "Privater Schlüssel",
|
||||
"updateCertificate": "Zertifikat aktualisieren",
|
||||
"updatingCertificate": "Wird aktualisiert...",
|
||||
"disabling": "Deaktivieren...",
|
||||
"restarting": "Neustart...",
|
||||
"disableHttps": "Deaktivieren Sie HTTPS",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"notMonitored": "not monitored",
|
||||
"namespaceRestricted": "namespace-restricted",
|
||||
"namespaceRestrictedTitle": "Storage reachable; datastore size hidden by ACL (e.g. PBS DatastoreAdmin on a single namespace)",
|
||||
"capacityNotReported": "Capacity not reported",
|
||||
"managedByProxmox": "managed by Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "stale",
|
||||
@@ -1283,7 +1284,6 @@
|
||||
"dockerImagesTitle": "Docker images",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Images",
|
||||
"dockerImagesReadOnly": "Image tags are compared by registry digest. Compose services can be pulled and recreated from their declared project.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} is package-managed and is updated by the OS packages action above.",
|
||||
"dockerEngineSubheading": "Engine",
|
||||
"dockerEngineDetected": "Docker Engine {version} detected",
|
||||
@@ -2175,6 +2175,7 @@
|
||||
"noTokenReceived": "No token was returned by the server.",
|
||||
"generateTokenRetry": "Failed to generate token. Please try again.",
|
||||
"configureSslFailed": "Failed to configure SSL",
|
||||
"reloadSslFailed": "Failed to update the active certificate",
|
||||
"disableSslFailed": "Failed to disable SSL",
|
||||
"deleteReportFailed": "Failed to delete report"
|
||||
},
|
||||
@@ -2202,6 +2203,8 @@
|
||||
"apiTokenGenerated": "API token was generated.",
|
||||
"sslEnabledRestarting": "HTTPS is enabled. Restarting service...",
|
||||
"sslDisabledRestarting": "HTTPS is disabled. Restarting service...",
|
||||
"sslCertificateReloaded": "The renewed certificate is now active. Existing connections were not interrupted.",
|
||||
"sslCertificateUnchanged": "The active certificate already matches the current certificate files.",
|
||||
"reportDeleted": "Audit report was deleted.",
|
||||
"twoFactorEnabled": "Two-factor authentication is enabled."
|
||||
},
|
||||
@@ -2266,6 +2269,8 @@
|
||||
"activeCertificate": "Active certificate",
|
||||
"cert": "Certificate",
|
||||
"key": "Private key",
|
||||
"updateCertificate": "Update certificate",
|
||||
"updatingCertificate": "Updating...",
|
||||
"disabling": "Disabling...",
|
||||
"restarting": "Restarting...",
|
||||
"disableHttps": "Disable HTTPS",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "no monitoreado",
|
||||
"namespaceRestricted": "espacio de nombres restringido",
|
||||
"namespaceRestrictedTitle": "Almacenamiento accesible; Tamaño del almacén de datos oculto por ACL (por ejemplo, PBS DatastoreAdmin en un único espacio de nombres)",
|
||||
"capacityNotReported": "Capacidad no informada",
|
||||
"managedByProxmox": "gestionado por Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "duro",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Imágenes Docker",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Imágenes",
|
||||
"dockerImagesReadOnly": "Las etiquetas se comparan mediante el digest del registro. Los servicios Compose pueden descargar la imagen y recrearse desde su proyecto declarado.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} está gestionado por paquetes y se actualiza mediante la acción de paquetes del SO anterior.",
|
||||
"dockerEngineSubheading": "Motor",
|
||||
"dockerEngineDetected": "Docker Engine {version} detectado",
|
||||
@@ -1688,7 +1688,7 @@
|
||||
"label": "Seguridad"
|
||||
},
|
||||
"cluster": {
|
||||
"label": "Grupo"
|
||||
"label": "Clúster"
|
||||
},
|
||||
"services": {
|
||||
"label": "Servicios"
|
||||
@@ -1758,13 +1758,13 @@
|
||||
"burst_auth_fail": "Ráfaga de fallos de autenticación",
|
||||
"burst_ip_block": "Ráfaga de bloqueos de IP",
|
||||
"burst_disk_io": "Ráfaga de errores de E/S de disco",
|
||||
"burst_cluster": "Conexión de clúster aleteando",
|
||||
"burst_cluster": "Conexión del clúster inestable",
|
||||
"burst_service_fail": "Múltiples fallas de servicio",
|
||||
"burst_system": "Múltiples problemas del sistema",
|
||||
"burst_generic": "Múltiples eventos relacionados",
|
||||
"split_brain": "Cerebro dividido detectado",
|
||||
"node_disconnect": "Nodo desconectado",
|
||||
"node_reconnect": "Nodo reconectado",
|
||||
"split_brain": "Partición del clúster detectada",
|
||||
"node_disconnect": "Nodo del clúster desconectado",
|
||||
"node_reconnect": "Nodo del clúster reconectado",
|
||||
"system_startup": "Informe de inicio del sistema",
|
||||
"system_shutdown": "Sistema apagándose",
|
||||
"system_reboot": "Reinicio del sistema",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "El servidor no devolvió ningún token.",
|
||||
"generateTokenRetry": "No se pudo generar el token. Por favor inténtalo de nuevo.",
|
||||
"configureSslFailed": "No se pudo configurar SSL",
|
||||
"reloadSslFailed": "No se pudo actualizar el certificado activo",
|
||||
"disableSslFailed": "No se pudo deshabilitar SSL",
|
||||
"deleteReportFailed": "No se pudo eliminar el informe"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "Se generó el token API.",
|
||||
"sslEnabledRestarting": "HTTPS está habilitado. Reiniciando servicio...",
|
||||
"sslDisabledRestarting": "HTTPS está deshabilitado. Reiniciando servicio...",
|
||||
"sslCertificateReloaded": "El certificado renovado ya está activo. No se ha interrumpido ninguna conexión.",
|
||||
"sslCertificateUnchanged": "El certificado activo ya coincide con los archivos actuales.",
|
||||
"reportDeleted": "Se eliminó el informe de auditoría.",
|
||||
"twoFactorEnabled": "La autenticación de dos factores está habilitada."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Certificado activo",
|
||||
"cert": "Certificado",
|
||||
"key": "clave privada",
|
||||
"updateCertificate": "Actualizar certificado",
|
||||
"updatingCertificate": "Actualizando...",
|
||||
"disabling": "Desactivando...",
|
||||
"restarting": "Reiniciando...",
|
||||
"disableHttps": "Deshabilitar HTTPS",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "non surveillé",
|
||||
"namespaceRestricted": "espace de noms restreint",
|
||||
"namespaceRestrictedTitle": "Stockage accessible ; taille de la banque de données masquée par l'ACL (par exemple PBS DatastoreAdmin sur un seul espace de noms)",
|
||||
"capacityNotReported": "Capacité non indiquée",
|
||||
"managedByProxmox": "géré par Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "vicié",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Images Docker",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Images",
|
||||
"dockerImagesReadOnly": "Les tags sont comparés par digest de registre. Les services Compose peuvent télécharger l’image et être recréés depuis leur projet déclaré.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} est géré par paquets et mis à jour par l’action des paquets du SE ci-dessus.",
|
||||
"dockerEngineSubheading": "Moteur",
|
||||
"dockerEngineDetected": "Docker Engine {version} détecté",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "Aucun jeton n'a été renvoyé par le serveur.",
|
||||
"generateTokenRetry": "Échec de la génération du jeton. Veuillez réessayer.",
|
||||
"configureSslFailed": "Échec de la configuration de SSL",
|
||||
"reloadSslFailed": "Échec de la mise à jour du certificat actif",
|
||||
"disableSslFailed": "Échec de la désactivation de SSL",
|
||||
"deleteReportFailed": "Échec de la suppression du rapport"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "Le jeton API a été généré.",
|
||||
"sslEnabledRestarting": "HTTPS est activé. Redémarrage du service...",
|
||||
"sslDisabledRestarting": "HTTPS est désactivé. Redémarrage du service...",
|
||||
"sslCertificateReloaded": "Le certificat renouvelé est maintenant actif. Les connexions existantes n’ont pas été interrompues.",
|
||||
"sslCertificateUnchanged": "Le certificat actif correspond déjà aux fichiers de certificat actuels.",
|
||||
"reportDeleted": "Le rapport d'audit a été supprimé.",
|
||||
"twoFactorEnabled": "L'authentification à deux facteurs est activée."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Certificat actif",
|
||||
"cert": "Certificat",
|
||||
"key": "Clé privée",
|
||||
"updateCertificate": "Mettre à jour le certificat",
|
||||
"updatingCertificate": "Mise à jour...",
|
||||
"disabling": "Désactivation...",
|
||||
"restarting": "Redémarrage...",
|
||||
"disableHttps": "Désactiver HTTPS",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "non monitorato",
|
||||
"namespaceRestricted": "limitato allo spazio dei nomi",
|
||||
"namespaceRestrictedTitle": "Deposito raggiungibile; dimensione del datastore nascosta dall'ACL (ad esempio PBS DatastoreAdmin su un singolo spazio dei nomi)",
|
||||
"capacityNotReported": "Capacità non indicata",
|
||||
"managedByProxmox": "gestito da Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "stantio",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Immagini Docker",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Immagini",
|
||||
"dockerImagesReadOnly": "I tag vengono confrontati tramite digest del registro. I servizi Compose possono scaricare l’immagine ed essere ricreati dal progetto dichiarato.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} è gestito dai pacchetti e viene aggiornato dall’azione dei pacchetti del sistema operativo qui sopra.",
|
||||
"dockerEngineSubheading": "Motore",
|
||||
"dockerEngineDetected": "Docker Engine {version} rilevato",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "Nessun token è stato restituito dal server.",
|
||||
"generateTokenRetry": "Impossibile generare il token. Per favore riprova.",
|
||||
"configureSslFailed": "Impossibile configurare SSL",
|
||||
"reloadSslFailed": "Impossibile aggiornare il certificato attivo",
|
||||
"disableSslFailed": "Impossibile disabilitare SSL",
|
||||
"deleteReportFailed": "Impossibile eliminare il rapporto"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "Il token API è stato generato.",
|
||||
"sslEnabledRestarting": "HTTPS è abilitato. Riavvio del servizio...",
|
||||
"sslDisabledRestarting": "HTTPS è disabilitato. Riavvio del servizio...",
|
||||
"sslCertificateReloaded": "Il certificato rinnovato è ora attivo. Le connessioni esistenti non sono state interrotte.",
|
||||
"sslCertificateUnchanged": "Il certificato attivo corrisponde già ai file del certificato correnti.",
|
||||
"reportDeleted": "Il rapporto di audit è stato eliminato.",
|
||||
"twoFactorEnabled": "L'autenticazione a due fattori è abilitata."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Certificato attivo",
|
||||
"cert": "Certificato",
|
||||
"key": "Chiave privata",
|
||||
"updateCertificate": "Aggiorna certificato",
|
||||
"updatingCertificate": "Aggiornamento...",
|
||||
"disabling": "Disabilitazione...",
|
||||
"restarting": "Riavvio...",
|
||||
"disableHttps": "Disabilita HTTPS",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "não monitorado",
|
||||
"namespaceRestricted": "restrito ao namespace",
|
||||
"namespaceRestrictedTitle": "Armazenamento acessível; tamanho do armazenamento de dados oculto pela ACL (por exemplo, PBS DatastoreAdmin em um único namespace)",
|
||||
"capacityNotReported": "Capacidade não informada",
|
||||
"managedByProxmox": "gerenciado por Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "obsoleto",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Imagens Docker",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Imagens",
|
||||
"dockerImagesReadOnly": "As tags são comparadas pelo digest do registo. Os serviços Compose podem descarregar a imagem e ser recriados a partir do projeto declarado.",
|
||||
"dockerEngineManagedByOs": "O Docker Engine {version} é gerido por pacotes e atualizado pela ação de pacotes do SO acima.",
|
||||
"dockerEngineSubheading": "Motor",
|
||||
"dockerEngineDetected": "Docker Engine {version} detetado",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "Nenhum token foi retornado pelo servidor.",
|
||||
"generateTokenRetry": "Falha ao gerar token. Por favor, tente novamente.",
|
||||
"configureSslFailed": "Falha ao configurar SSL",
|
||||
"reloadSslFailed": "Falha ao atualizar o certificado ativo",
|
||||
"disableSslFailed": "Falha ao desativar SSL",
|
||||
"deleteReportFailed": "Falha ao excluir relatório"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "O token da API foi gerado.",
|
||||
"sslEnabledRestarting": "HTTPS está ativado. Reiniciando serviço...",
|
||||
"sslDisabledRestarting": "HTTPS está desativado. Reiniciando serviço...",
|
||||
"sslCertificateReloaded": "O certificado renovado está agora ativo. As ligações existentes não foram interrompidas.",
|
||||
"sslCertificateUnchanged": "O certificado ativo já corresponde aos ficheiros de certificado atuais.",
|
||||
"reportDeleted": "O relatório de auditoria foi excluído.",
|
||||
"twoFactorEnabled": "A autenticação de dois fatores está habilitada."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Certificado ativo",
|
||||
"cert": "Certificado",
|
||||
"key": "Chave privada",
|
||||
"updateCertificate": "Atualizar certificado",
|
||||
"updatingCertificate": "A atualizar...",
|
||||
"disabling": "Desativando...",
|
||||
"restarting": "Reiniciando...",
|
||||
"disableHttps": "Desativar HTTPS",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"notMonitored": "nesledované",
|
||||
"namespaceRestricted": "obmedzený prístup",
|
||||
"namespaceRestrictedTitle": "Úložisko je dostupné, ale veľkosť datastore je skrytá právami ACL (napr. PBS DatastoreAdmin iba pre jeden namespace)",
|
||||
"capacityNotReported": "Kapacita nie je uvedená",
|
||||
"managedByProxmox": "spravuje Proxmox",
|
||||
"readOnlyShort": "iba čítanie",
|
||||
"stale": "neodpovedá",
|
||||
@@ -1283,7 +1284,6 @@
|
||||
"dockerImagesTitle": "Docker obrazy",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Obrazy",
|
||||
"dockerImagesReadOnly": "Značky sa porovnávajú podľa digestu registra. Služby Compose môžu stiahnuť obraz a znova sa vytvoriť z deklarovaného projektu.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} je spravovaný balíkmi a aktualizuje sa vyššie uvedenou akciou balíkov OS.",
|
||||
"dockerEngineSubheading": "Engine",
|
||||
"dockerEngineDetected": "Zistený Docker Engine {version}",
|
||||
@@ -2175,6 +2175,7 @@
|
||||
"noTokenReceived": "Server nevrátil žiadny token.",
|
||||
"generateTokenRetry": "Nepodarilo sa vytvoriť token. Skúste to znova.",
|
||||
"configureSslFailed": "Nepodarilo sa nastaviť SSL",
|
||||
"reloadSslFailed": "Aktívny certifikát sa nepodarilo aktualizovať",
|
||||
"disableSslFailed": "Nepodarilo sa vypnúť SSL",
|
||||
"deleteReportFailed": "Nepodarilo sa vymazať report"
|
||||
},
|
||||
@@ -2202,6 +2203,8 @@
|
||||
"apiTokenGenerated": "API token bol vytvorený.",
|
||||
"sslEnabledRestarting": "HTTPS je zapnuté. Reštartujem službu...",
|
||||
"sslDisabledRestarting": "HTTPS je vypnuté. Reštartujem službu...",
|
||||
"sslCertificateReloaded": "Obnovený certifikát je teraz aktívny. Existujúce pripojenia neboli prerušené.",
|
||||
"sslCertificateUnchanged": "Aktívny certifikát už zodpovedá aktuálnym súborom certifikátu.",
|
||||
"reportDeleted": "Správa z auditu bola vymazaná.",
|
||||
"twoFactorEnabled": "Dvojfaktorové overenie je zapnuté."
|
||||
},
|
||||
@@ -2266,6 +2269,8 @@
|
||||
"activeCertificate": "Aktívny certifikát",
|
||||
"cert": "Certifikát",
|
||||
"key": "Súkromný kľúč",
|
||||
"updateCertificate": "Aktualizovať certifikát",
|
||||
"updatingCertificate": "Aktualizuje sa...",
|
||||
"disabling": "Vypínam...",
|
||||
"restarting": "Reštartujem...",
|
||||
"disableHttps": "Vypnúť HTTPS",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"notMonitored": "inte övervakas",
|
||||
"namespaceRestricted": "namnutrymmet begränsat",
|
||||
"namespaceRestrictedTitle": "Lagring nåbar; datalagerstorlek dold av ACL (t.ex. PBS DatastoreAdmin på ett enda namnområde)",
|
||||
"capacityNotReported": "Kapacitet ej rapporterad",
|
||||
"managedByProxmox": "hanteras av Proxmox",
|
||||
"readOnlyShort": "ro",
|
||||
"stale": "Inaktuell",
|
||||
@@ -1284,7 +1285,6 @@
|
||||
"dockerImagesTitle": "Docker-avbilder",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Avbilder",
|
||||
"dockerImagesReadOnly": "Taggar jämförs med registrets digest. Compose-tjänster kan hämta avbilden och återskapas från sitt deklarerade projekt.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} hanteras som paket och uppdateras av OS-paketåtgärden ovan.",
|
||||
"dockerEngineSubheading": "Motor",
|
||||
"dockerEngineDetected": "Docker Engine {version} identifierad",
|
||||
@@ -2176,6 +2176,7 @@
|
||||
"noTokenReceived": "Ingen token returnerades av servern.",
|
||||
"generateTokenRetry": "Det gick inte att generera token. Försök igen.",
|
||||
"configureSslFailed": "Det gick inte att konfigurera SSL",
|
||||
"reloadSslFailed": "Det gick inte att uppdatera det aktiva certifikatet",
|
||||
"disableSslFailed": "Det gick inte att inaktivera SSL",
|
||||
"deleteReportFailed": "Det gick inte att ta bort rapporten"
|
||||
},
|
||||
@@ -2203,6 +2204,8 @@
|
||||
"apiTokenGenerated": "API-token genererades.",
|
||||
"sslEnabledRestarting": "HTTPS är aktiverat. Startar om tjänsten...",
|
||||
"sslDisabledRestarting": "HTTPS är inaktiverat. Startar om tjänsten...",
|
||||
"sslCertificateReloaded": "Det förnyade certifikatet är nu aktivt. Befintliga anslutningar avbröts inte.",
|
||||
"sslCertificateUnchanged": "Det aktiva certifikatet motsvarar redan de aktuella certifikatfilerna.",
|
||||
"reportDeleted": "Revisionsrapporten togs bort.",
|
||||
"twoFactorEnabled": "Tvåfaktorsautentisering är aktiverad."
|
||||
},
|
||||
@@ -2267,6 +2270,8 @@
|
||||
"activeCertificate": "Aktivt certifikat",
|
||||
"cert": "Certifikat",
|
||||
"key": "Privat nyckel",
|
||||
"updateCertificate": "Uppdatera certifikat",
|
||||
"updatingCertificate": "Uppdaterar...",
|
||||
"disabling": "Inaktiverar...",
|
||||
"restarting": "Startar om...",
|
||||
"disableHttps": "Inaktivera HTTPS",
|
||||
|
||||
@@ -1054,6 +1054,12 @@ PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
|
||||
PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
|
||||
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
|
||||
|
||||
_SSL_RUNTIME_LOCK = threading.RLock()
|
||||
_SSL_RUNTIME_CONTEXT = None
|
||||
_SSL_RUNTIME_FINGERPRINT = ""
|
||||
_SSL_RUNTIME_CERT_PATH = ""
|
||||
_SSL_RUNTIME_KEY_PATH = ""
|
||||
|
||||
|
||||
def load_ssl_config():
|
||||
"""Load SSL configuration from file"""
|
||||
@@ -1175,26 +1181,84 @@ def validate_certificate_files(cert_path, key_path):
|
||||
except Exception as e:
|
||||
return False, f"Error reading certificate files: {str(e)}"
|
||||
|
||||
# Verify cert and key match
|
||||
# Parse the complete chain and verify that the private key matches it.
|
||||
try:
|
||||
import subprocess
|
||||
cert_mod = subprocess.run(
|
||||
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
key_mod = subprocess.run(
|
||||
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if cert_mod.returncode == 0 and key_mod.returncode == 0:
|
||||
if cert_mod.stdout.strip() != key_mod.stdout.strip():
|
||||
return False, "Certificate and key do not match"
|
||||
except Exception:
|
||||
pass # Non-critical, proceed anyway
|
||||
import ssl
|
||||
test_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
test_context.load_cert_chain(cert_path, key_path)
|
||||
except Exception as e:
|
||||
return False, f"Certificate or private key is invalid: {str(e)}"
|
||||
|
||||
return True, "Certificate files are valid"
|
||||
|
||||
|
||||
def _certificate_pair_fingerprint(cert_path, key_path):
|
||||
digest = hashlib.sha256()
|
||||
for path in (cert_path, key_path):
|
||||
with open(path, "rb") as source:
|
||||
for chunk in iter(lambda: source.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _build_server_ssl_context(cert_path, key_path):
|
||||
import ssl
|
||||
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(cert_path, key_path)
|
||||
return context
|
||||
|
||||
|
||||
def create_reloadable_ssl_context(cert_path, key_path):
|
||||
"""Create the server context and register it for manual hot reloads."""
|
||||
global _SSL_RUNTIME_CONTEXT
|
||||
global _SSL_RUNTIME_FINGERPRINT
|
||||
global _SSL_RUNTIME_CERT_PATH
|
||||
global _SSL_RUNTIME_KEY_PATH
|
||||
|
||||
context = _build_server_ssl_context(cert_path, key_path)
|
||||
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
|
||||
def _select_active_context(ssl_socket, _server_name, _initial_context):
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
active_context = _SSL_RUNTIME_CONTEXT
|
||||
if active_context is not None and ssl_socket.context is not active_context:
|
||||
ssl_socket.context = active_context
|
||||
|
||||
context.sni_callback = _select_active_context
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
_SSL_RUNTIME_CONTEXT = context
|
||||
_SSL_RUNTIME_FINGERPRINT = fingerprint
|
||||
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||
_SSL_RUNTIME_KEY_PATH = key_path
|
||||
return context
|
||||
|
||||
|
||||
def reload_server_ssl_context(cert_path, key_path):
|
||||
"""Validate and activate a new certificate for subsequent TLS handshakes."""
|
||||
global _SSL_RUNTIME_CONTEXT
|
||||
global _SSL_RUNTIME_FINGERPRINT
|
||||
global _SSL_RUNTIME_CERT_PATH
|
||||
global _SSL_RUNTIME_KEY_PATH
|
||||
|
||||
before_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
replacement = _build_server_ssl_context(cert_path, key_path)
|
||||
after_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
if before_fingerprint != after_fingerprint:
|
||||
raise RuntimeError("Certificate files changed while they were being loaded")
|
||||
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
if _SSL_RUNTIME_CONTEXT is None:
|
||||
raise RuntimeError("The HTTPS runtime is not initialized")
|
||||
changed = after_fingerprint != _SSL_RUNTIME_FINGERPRINT
|
||||
if changed:
|
||||
_SSL_RUNTIME_CONTEXT = replacement
|
||||
_SSL_RUNTIME_FINGERPRINT = after_fingerprint
|
||||
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||
_SSL_RUNTIME_KEY_PATH = key_path
|
||||
return changed
|
||||
|
||||
|
||||
def configure_ssl(cert_path, key_path, source="custom"):
|
||||
"""
|
||||
Configure SSL with given certificate and key paths.
|
||||
|
||||
@@ -249,6 +249,78 @@ def ssl_disable():
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/ssl/reload', methods=['POST'])
|
||||
@require_auth
|
||||
def ssl_reload():
|
||||
"""Reload the configured certificate without restarting the Monitor."""
|
||||
config = auth_manager.load_ssl_config()
|
||||
if not config.get("enabled"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "ssl_not_enabled",
|
||||
"message": "HTTPS is not enabled",
|
||||
}), 400
|
||||
|
||||
source = config.get("source", "custom")
|
||||
cert_info = None
|
||||
if source == "proxmox":
|
||||
detection = auth_manager.detect_proxmox_certificates()
|
||||
if not detection.get("proxmox_available"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "certificate_unavailable",
|
||||
"message": "No Proxmox certificate was detected",
|
||||
}), 404
|
||||
cert_path = detection.get("proxmox_cert", "")
|
||||
key_path = detection.get("proxmox_key", "")
|
||||
cert_info = detection.get("cert_info")
|
||||
else:
|
||||
cert_path = config.get("cert_path", "")
|
||||
key_path = config.get("key_path", "")
|
||||
|
||||
valid, validation_message = auth_manager.validate_certificate_files(cert_path, key_path)
|
||||
if not valid:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "certificate_invalid",
|
||||
"message": validation_message,
|
||||
}), 400
|
||||
|
||||
paths_changed = (
|
||||
cert_path != config.get("cert_path", "") or
|
||||
key_path != config.get("key_path", "")
|
||||
)
|
||||
if paths_changed:
|
||||
updated_config = dict(config)
|
||||
updated_config["cert_path"] = cert_path
|
||||
updated_config["key_path"] = key_path
|
||||
if not auth_manager.save_ssl_config(updated_config):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "config_save_failed",
|
||||
"message": "Failed to save the renewed certificate paths",
|
||||
}), 500
|
||||
|
||||
try:
|
||||
changed = auth_manager.reload_server_ssl_context(cert_path, key_path)
|
||||
except Exception as e:
|
||||
if paths_changed:
|
||||
auth_manager.save_ssl_config(config)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "runtime_reload_failed",
|
||||
"message": str(e),
|
||||
}), 409
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"changed": changed,
|
||||
"cert_path": cert_path,
|
||||
"key_path": key_path,
|
||||
"cert_info": cert_info,
|
||||
})
|
||||
|
||||
|
||||
def _refresh_pve_webhook_for_ssl_change():
|
||||
"""Helper used by both `ssl_configure` and `ssl_disable`.
|
||||
|
||||
|
||||
@@ -454,6 +454,7 @@ def get_remote_storages():
|
||||
'used': storage.get('used', 0),
|
||||
'available': storage.get('available', 0),
|
||||
'percent': storage.get('percent', 0),
|
||||
'capacity_known': storage.get('capacity_known', storage.get('total', 0) > 0),
|
||||
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
||||
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
||||
'excluded_at': exclusion.get('excluded_at'),
|
||||
|
||||
@@ -79,7 +79,7 @@ from smartctl_resolver import ( # noqa: E402
|
||||
)
|
||||
from flask_script_runner import script_runner
|
||||
import threading
|
||||
from proxmox_storage_monitor import proxmox_storage_monitor
|
||||
from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor
|
||||
from flask_terminal_routes import ( # noqa: E402
|
||||
terminal_bp,
|
||||
init_terminal_routes,
|
||||
@@ -5328,25 +5328,14 @@ def _get_proxmox_storage_uncached():
|
||||
used_gb = round(used / (1024**3), 2)
|
||||
available_gb = round(available / (1024**3), 2)
|
||||
|
||||
# Determine storage status. Sprint 11.6: a remote PBS where the
|
||||
# user only has DatastoreAdmin on their own namespace reports
|
||||
# `status=available` + `total=0` — the storage IS reachable, the
|
||||
# ACL just hides the datastore size. Surface as
|
||||
# 'namespace_restricted' so the UI can render INFO instead of
|
||||
# CRITICAL. Real outages still flag (status != available).
|
||||
if total == 0 and status.lower() == "available" and storage_type == 'pbs':
|
||||
storage_status = 'namespace_restricted'
|
||||
elif total == 0:
|
||||
storage_status = 'error'
|
||||
elif status.lower() != "available":
|
||||
storage_status = 'error'
|
||||
else:
|
||||
storage_status = 'active'
|
||||
storage_state = classify_storage_state(storage_type, status, total)
|
||||
|
||||
storage_info = {
|
||||
'name': name,
|
||||
'type': storage_type,
|
||||
'status': storage_status, # Usar el status determinado (active o error)
|
||||
'status': storage_state['status'],
|
||||
'status_detail': storage_state['status_detail'],
|
||||
'capacity_known': storage_state['capacity_known'],
|
||||
'total': total_gb,
|
||||
'used': used_gb,
|
||||
'available': available_gb,
|
||||
@@ -22172,8 +22161,7 @@ if __name__ == '__main__':
|
||||
from gevent import pywsgi
|
||||
import ssl
|
||||
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||
ssl_context = auth_manager.create_reloadable_ssl_context(ssl_cert, ssl_key)
|
||||
|
||||
# Defensive: silence the ~30-line traceback that gevent
|
||||
# prints whenever a client sends plain HTTP against this
|
||||
@@ -22242,9 +22230,7 @@ if __name__ == '__main__':
|
||||
except ImportError as e:
|
||||
print(f"[ProxMenux] gevent not available ({e})")
|
||||
# Fallback: Flask dev server with SSL - flask-sock handles WebSockets
|
||||
import ssl
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||
ssl_context = auth_manager.create_reloadable_ssl_context(ssl_cert, ssl_key)
|
||||
print("[ProxMenux] Starting Flask server with SSL (using flask-sock for WebSockets)...")
|
||||
app.run(host='::', port=8008, debug=False, ssl_context=ssl_context, threaded=True)
|
||||
else:
|
||||
|
||||
@@ -19,6 +19,7 @@ from collections import defaultdict
|
||||
import re
|
||||
|
||||
from health_persistence import health_persistence, disk_base_name
|
||||
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
|
||||
from smartctl_resolver import (
|
||||
is_usb_disk as resolver_is_usb_disk,
|
||||
probe_smartctl_json,
|
||||
@@ -4027,10 +4028,21 @@ class HealthMonitor:
|
||||
return reason
|
||||
|
||||
# Out of memory
|
||||
if 'out of memory' in line_lower or 'oom_kill' in line_lower:
|
||||
m = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line)
|
||||
process = m.group(1) if m else 'unknown'
|
||||
return f'Out of memory - system killed process "{process}" to free RAM'
|
||||
if any(token in line_lower for token in (
|
||||
'out of memory', 'oom_kill', 'oom-kill', 'invoked oom-killer', 'oom_reaper'
|
||||
)):
|
||||
victim = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line, re.IGNORECASE)
|
||||
if victim:
|
||||
return f'Memory pressure - kernel killed process "{victim.group(1)}"'
|
||||
|
||||
invoker = re.search(r'(?:kernel:\s*)?([^\s:]+)\s+invoked oom-killer', line, re.IGNORECASE)
|
||||
if invoker:
|
||||
return (
|
||||
f'Memory pressure triggered the OOM killer while "{invoker.group(1)}" '
|
||||
'requested memory; this process is not necessarily the main consumer'
|
||||
)
|
||||
|
||||
return 'Memory pressure triggered the OOM killer; inspect the complete kernel OOM block'
|
||||
|
||||
# Kernel panic
|
||||
if 'kernel panic' in line_lower:
|
||||
@@ -4152,6 +4164,9 @@ class HealthMonitor:
|
||||
if result_recent.returncode == 0:
|
||||
recent_lines = result_recent.stdout.strip().split('\n')
|
||||
previous_lines = result_previous.stdout.strip().split('\n') if result_previous.returncode == 0 else []
|
||||
recent_oom_analysis = analyze_oom_event(result_recent.stdout)
|
||||
recent_oom_reason = format_oom_diagnosis(recent_oom_analysis)
|
||||
processed_oom_patterns = set()
|
||||
|
||||
recent_patterns = defaultdict(int)
|
||||
previous_patterns = defaultdict(int)
|
||||
@@ -4172,7 +4187,22 @@ class HealthMonitor:
|
||||
continue
|
||||
|
||||
# Normalize to a pattern for grouping
|
||||
pattern = self._normalize_log_pattern(line)
|
||||
is_oom_line = any(token in line.lower() for token in (
|
||||
'out of memory', 'oom_kill', 'oom-kill',
|
||||
'invoked oom-killer', 'oom_reaper'
|
||||
))
|
||||
if is_oom_line and recent_oom_analysis:
|
||||
scope = recent_oom_analysis.get('scope') or 'unknown'
|
||||
scope_id = recent_oom_analysis.get('ctid') \
|
||||
or recent_oom_analysis.get('cgroup_path') \
|
||||
or 'unknown'
|
||||
victim = recent_oom_analysis.get('victim_process') or 'unknown'
|
||||
pattern = f'oom_event_{scope}_{scope_id}_{victim}'
|
||||
if pattern in processed_oom_patterns:
|
||||
continue
|
||||
processed_oom_patterns.add(pattern)
|
||||
else:
|
||||
pattern = self._normalize_log_pattern(line)
|
||||
|
||||
if severity == 'CRITICAL':
|
||||
pattern_hash = hashlib.md5(pattern.encode()).hexdigest()[:8]
|
||||
@@ -4213,7 +4243,10 @@ class HealthMonitor:
|
||||
if severity == 'CRITICAL':
|
||||
critical_errors_found[pattern] = line
|
||||
# Build a human-readable reason from the raw log line
|
||||
enriched_reason = self._enrich_critical_log_reason(line)
|
||||
if is_oom_line and recent_oom_reason:
|
||||
enriched_reason = recent_oom_reason
|
||||
else:
|
||||
enriched_reason = self._enrich_critical_log_reason(line)
|
||||
|
||||
# Append SMART context to the reason if we checked it
|
||||
if smart_status_for_log == 'PASSED':
|
||||
@@ -4230,9 +4263,13 @@ class HealthMonitor:
|
||||
category='logs',
|
||||
severity=severity,
|
||||
reason=enriched_reason,
|
||||
details={'pattern': pattern, 'raw_line': line[:200],
|
||||
'smart_status': smart_status_for_log,
|
||||
'dismissable': True}
|
||||
details={
|
||||
'pattern': pattern,
|
||||
'raw_line': line[:200],
|
||||
'smart_status': smart_status_for_log,
|
||||
'oom_analysis': recent_oom_analysis if is_oom_line else None,
|
||||
'dismissable': True,
|
||||
}
|
||||
)
|
||||
|
||||
# Cross-reference: filesystem errors also belong in the disks category
|
||||
|
||||
@@ -25,6 +25,8 @@ from queue import Queue
|
||||
from typing import Optional, Dict, Any, Tuple, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
|
||||
|
||||
|
||||
# ─── Shared State for Cross-Watcher Coordination ──────────────────
|
||||
|
||||
@@ -489,6 +491,12 @@ class JournalWatcher:
|
||||
self._recent_events: Dict[str, float] = {}
|
||||
self._dedup_window = 30 # seconds
|
||||
|
||||
# Linux emits an OOM diagnosis as a multi-line kernel block. Buffer it
|
||||
# until the authoritative `Killed process` line arrives so the alert
|
||||
# can distinguish a host OOM from a memory-cgroup/LXC limit.
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
|
||||
# 24h anti-cascade for disk I/O + filesystem errors. The dict
|
||||
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
|
||||
# so a disk in WARNING cooldown can still escalate to CRITICAL
|
||||
@@ -830,6 +838,57 @@ class JournalWatcher:
|
||||
# Only process messages from kernel or systemd (not app-level logs)
|
||||
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if self._oom_lines and now - self._oom_started_at > 15:
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
|
||||
starts_oom_block = bool(re.search(
|
||||
r'invoked oom-killer|oom-kill:constraint=', msg, re.IGNORECASE
|
||||
))
|
||||
ends_oom_block = bool(re.search(
|
||||
r'(?:Memory cgroup )?Out of memory:\s+Killed process', msg, re.IGNORECASE
|
||||
))
|
||||
|
||||
if starts_oom_block and not self._oom_lines:
|
||||
self._oom_lines = [msg]
|
||||
self._oom_started_at = now
|
||||
return
|
||||
|
||||
if self._oom_lines:
|
||||
self._oom_lines.append(msg)
|
||||
if len(self._oom_lines) > 500:
|
||||
self._oom_lines = self._oom_lines[-500:]
|
||||
|
||||
if ends_oom_block:
|
||||
analysis = analyze_oom_event('\n'.join(self._oom_lines))
|
||||
reason = format_oom_diagnosis(analysis)
|
||||
if not reason:
|
||||
reason = f'Out of memory killer activated\n{msg[:300]}'
|
||||
|
||||
ctid = analysis.get('ctid') if analysis else ''
|
||||
victim = analysis.get('victim_process') if analysis else ''
|
||||
entity_id = f'lxc_{ctid}' if ctid else f'oom_{victim or "unknown"}'
|
||||
self._emit(
|
||||
'system_problem',
|
||||
'CRITICAL',
|
||||
{
|
||||
'reason': reason,
|
||||
'hostname': self._hostname,
|
||||
'oom_analysis': analysis or {},
|
||||
},
|
||||
entity='node',
|
||||
entity_id=entity_id,
|
||||
)
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
return
|
||||
|
||||
# `Call Trace:` is part of the buffered OOM evidence, not a second
|
||||
# independent kernel fault requiring another notification.
|
||||
if re.search(r'^Call Trace:', msg, re.IGNORECASE):
|
||||
return
|
||||
|
||||
# Filter out normal kernel messages that are NOT problems
|
||||
_KERNEL_NOISE = [
|
||||
|
||||
@@ -18,6 +18,121 @@ Each entry includes:
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
def analyze_oom_event(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract the scope and victim from a complete Linux OOM block.
|
||||
|
||||
The process on the ``invoked oom-killer`` line is only the allocation
|
||||
trigger. The authoritative scope is carried by ``constraint`` and
|
||||
``oom_memcg``; the actual victim is carried by ``Killed process``.
|
||||
"""
|
||||
if not text or not re.search(
|
||||
r'invoked oom-killer|oom-kill:|memory cgroup out of memory|out of memory: killed process',
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return None
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
'scope': 'unknown',
|
||||
'constraint': '',
|
||||
'cgroup_path': '',
|
||||
'ctid': '',
|
||||
'invoker': '',
|
||||
'victim_process': '',
|
||||
'victim_pid': '',
|
||||
'memory_usage_kib': None,
|
||||
'memory_limit_kib': None,
|
||||
'swap_usage_kib': None,
|
||||
'swap_limit_kib': None,
|
||||
}
|
||||
|
||||
constraint = re.search(r'constraint=([A-Z0-9_]+)', text, re.IGNORECASE)
|
||||
if constraint:
|
||||
result['constraint'] = constraint.group(1).upper()
|
||||
|
||||
cgroup = re.search(r'oom_memcg=([^,\s]+)', text, re.IGNORECASE)
|
||||
if not cgroup:
|
||||
cgroup = re.search(r'Memory cgroup stats for\s+([^:\s]+)', text, re.IGNORECASE)
|
||||
if cgroup:
|
||||
result['cgroup_path'] = cgroup.group(1)
|
||||
|
||||
ctid = re.search(r'/lxc/(\d+)\b', result['cgroup_path'] or text, re.IGNORECASE)
|
||||
if ctid:
|
||||
result['ctid'] = ctid.group(1)
|
||||
result['scope'] = 'lxc'
|
||||
elif result['constraint'] == 'CONSTRAINT_MEMCG' or re.search(
|
||||
r'memory cgroup out of memory', text, re.IGNORECASE
|
||||
):
|
||||
result['scope'] = 'memory_cgroup'
|
||||
elif result['constraint']:
|
||||
result['scope'] = 'host'
|
||||
|
||||
invoker = re.search(r'\b([A-Za-z0-9_.+/-]+)\s+invoked oom-killer', text, re.IGNORECASE)
|
||||
if invoker:
|
||||
result['invoker'] = invoker.group(1)
|
||||
|
||||
victim = re.search(r'Killed process\s+(\d+)\s+\(([^)]+)\)', text, re.IGNORECASE)
|
||||
if victim:
|
||||
result['victim_pid'] = victim.group(1)
|
||||
result['victim_process'] = victim.group(2)
|
||||
|
||||
memory = re.search(
|
||||
r'memory:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
|
||||
)
|
||||
if memory:
|
||||
result['memory_usage_kib'] = int(memory.group(1))
|
||||
result['memory_limit_kib'] = int(memory.group(2))
|
||||
|
||||
swap = re.search(
|
||||
r'swap:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
|
||||
)
|
||||
if swap:
|
||||
result['swap_usage_kib'] = int(swap.group(1))
|
||||
result['swap_limit_kib'] = int(swap.group(2))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_oom_diagnosis(analysis: Optional[Dict[str, Any]]) -> str:
|
||||
"""Return a concise evidence-based diagnosis for an OOM analysis."""
|
||||
if not analysis:
|
||||
return ''
|
||||
|
||||
lines: List[str] = []
|
||||
scope = analysis.get('scope')
|
||||
ctid = analysis.get('ctid')
|
||||
if scope == 'lxc' and ctid:
|
||||
lines.append(f'OOM scope: LXC {ctid} memory cgroup (not a host-wide OOM)')
|
||||
elif scope == 'memory_cgroup':
|
||||
path = analysis.get('cgroup_path') or 'unknown cgroup'
|
||||
lines.append(f'OOM scope: memory cgroup {path} (not a host-wide OOM)')
|
||||
elif scope == 'host':
|
||||
lines.append('OOM scope: host/kernel memory scope')
|
||||
else:
|
||||
lines.append('OOM scope: not established from the available log lines')
|
||||
|
||||
usage = analysis.get('memory_usage_kib')
|
||||
limit = analysis.get('memory_limit_kib')
|
||||
if usage is not None and limit is not None:
|
||||
lines.append(f'Cgroup memory: {usage / 1024:.1f} MiB used of {limit / 1024:.1f} MiB')
|
||||
|
||||
swap_usage = analysis.get('swap_usage_kib')
|
||||
swap_limit = analysis.get('swap_limit_kib')
|
||||
if swap_usage is not None and swap_limit is not None:
|
||||
lines.append(f'Cgroup swap: {swap_usage / 1024:.1f} MiB used of {swap_limit / 1024:.1f} MiB')
|
||||
|
||||
victim = analysis.get('victim_process')
|
||||
victim_pid = analysis.get('victim_pid')
|
||||
if victim:
|
||||
lines.append(f'Killed process: {victim}' + (f' (PID {victim_pid})' if victim_pid else ''))
|
||||
|
||||
invoker = analysis.get('invoker')
|
||||
if invoker and invoker != victim:
|
||||
lines.append(f'Allocation trigger: {invoker} (not necessarily the largest consumer)')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
# Known error patterns with causes and solutions
|
||||
PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
|
||||
# ==================== SUBSCRIPTION/LICENSE ====================
|
||||
@@ -169,11 +284,11 @@ PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
|
||||
},
|
||||
{
|
||||
"pattern": r"out of memory|OOM.*kill|cannot allocate memory|memory.*exhausted",
|
||||
"cause": "System or VM ran out of memory",
|
||||
"cause_detailed": "The Linux OOM (Out Of Memory) killer terminated a process to free memory. This indicates memory pressure from overcommitment or memory leaks.",
|
||||
"cause": "The kernel invoked the OOM killer under memory pressure",
|
||||
"cause_detailed": "Linux could not satisfy a memory allocation in the relevant host, cgroup, cpuset or NUMA scope. The process named as having invoked the OOM killer only triggered the allocation; it is not necessarily the largest consumer or the process that was killed. The complete OOM block is required to identify the scope, victim and likely cause.",
|
||||
"severity": "critical",
|
||||
"solution": "Increase memory allocation or reduce VM memory usage",
|
||||
"solution_detailed": "1. Check what was killed: dmesg | grep -i oom\n2. Review memory usage: free -h\n3. Check balloon driver status for VMs\n4. Consider adding swap or RAM\n5. Review VM memory allocations for overcommitment",
|
||||
"solution": "Inspect the complete OOM event and current host/cgroup memory before changing allocations",
|
||||
"solution_detailed": "1. Read the complete kernel OOM block, including 'Killed process', 'Mem-Info' and task rows\n2. Determine whether it was a host-wide or memory-cgroup OOM\n3. Review free -h, swap, CommitLimit/Committed_AS and active VM/LXC allocations\n4. On ZFS hosts, compare ARC size and c_max with the configured zfs_arc_max\n5. Adjust the confirmed consumer, ARC cap or workload only after identifying the exhausted scope",
|
||||
"category": "memory"
|
||||
},
|
||||
|
||||
@@ -316,6 +431,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
error = find_matching_error(text, category)
|
||||
if not error:
|
||||
return None
|
||||
|
||||
oom_diagnosis = ''
|
||||
if error.get('category') == 'memory':
|
||||
oom_diagnosis = format_oom_diagnosis(analyze_oom_event(text))
|
||||
|
||||
# NOTE: we intentionally do NOT emit a "Severity:" line here.
|
||||
# The catalogue's severity is the *typical* severity of a class
|
||||
@@ -329,7 +448,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
# carried by the notification's own severity field; repeating a
|
||||
# different value here is noise at best, misinformation at worst.
|
||||
if detail_level == "minimal":
|
||||
return f"Known issue: {error['cause']}"
|
||||
result = f"Known issue: {error['cause']}"
|
||||
if oom_diagnosis:
|
||||
result += f"\n{oom_diagnosis}"
|
||||
return result
|
||||
|
||||
elif detail_level == "standard":
|
||||
lines = [
|
||||
@@ -339,6 +461,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Docs: {error['url']}")
|
||||
if oom_diagnosis:
|
||||
lines.append(" Event analysis:")
|
||||
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
|
||||
return "\n".join(lines)
|
||||
|
||||
else: # detailed
|
||||
@@ -349,6 +474,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Documentation: {error['url']}")
|
||||
if oom_diagnosis:
|
||||
lines.append(" Event analysis:")
|
||||
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,33 @@ import time
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
def classify_storage_state(storage_type: str, status: str, total: int) -> Dict[str, Any]:
|
||||
"""Classify reachability independently from reported capacity."""
|
||||
normalized_status = str(status or 'unknown').strip().lower()
|
||||
normalized_type = str(storage_type or 'unknown').strip().lower()
|
||||
capacity_known = total > 0
|
||||
|
||||
if normalized_status != 'available':
|
||||
return {
|
||||
'status': 'error',
|
||||
'status_detail': normalized_status or 'unknown',
|
||||
'capacity_known': capacity_known,
|
||||
}
|
||||
|
||||
if not capacity_known and normalized_type == 'pbs':
|
||||
return {
|
||||
'status': 'namespace_restricted',
|
||||
'status_detail': 'namespace_restricted',
|
||||
'capacity_known': False,
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'active',
|
||||
'status_detail': 'available' if capacity_known else 'capacity_unreported',
|
||||
'capacity_known': capacity_known,
|
||||
}
|
||||
|
||||
|
||||
class ProxmoxStorageMonitor:
|
||||
"""Monitor Proxmox storage configuration and status"""
|
||||
|
||||
@@ -177,28 +204,13 @@ class ProxmoxStorageMonitor:
|
||||
'percent': round(percent, 2),
|
||||
'node': node
|
||||
}
|
||||
|
||||
# Check if storage is available.
|
||||
#
|
||||
# "jc-pbs-friendly" mode (Sprint 11.6): a remote PBS where
|
||||
# the user only has DatastoreAdmin on their own namespace
|
||||
# reports `status=available` + `total=0` — the storage IS
|
||||
# reachable, the user just can't list the datastore size.
|
||||
# Treat that combination as INFO (namespace-restricted)
|
||||
# instead of CRITICAL so we don't spam the operator with
|
||||
# "almacenamiento no disponible" every poll. Real outages
|
||||
# still flag because they come back with `status != available`.
|
||||
if total == 0 and status.lower() == "available" and storage_type == 'pbs':
|
||||
storage_info['status'] = 'namespace_restricted'
|
||||
storage_info['status_detail'] = 'namespace_restricted'
|
||||
|
||||
state = classify_storage_state(storage_type, status, total)
|
||||
storage_info.update(state)
|
||||
if state['status'] in ('active', 'namespace_restricted'):
|
||||
available_storages.append(storage_info)
|
||||
elif total == 0 or status.lower() != "available":
|
||||
storage_info['status'] = 'error'
|
||||
storage_info['status_detail'] = 'unavailable' if total == 0 else status
|
||||
unavailable_storages.append(storage_info)
|
||||
else:
|
||||
storage_info['status'] = 'active'
|
||||
available_storages.append(storage_info)
|
||||
unavailable_storages.append(storage_info)
|
||||
|
||||
# Check for configured storages that are completely missing
|
||||
for storage_name, storage_config in self.configured_storages.items():
|
||||
@@ -212,6 +224,7 @@ class ProxmoxStorageMonitor:
|
||||
'used': 0,
|
||||
'available': 0,
|
||||
'percent': 0,
|
||||
'capacity_known': False,
|
||||
'node': local_node
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user