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:
MacRimi
2026-08-25 18:48:39 +02:00
parent 2302b0967b
commit 46158209f1
48 changed files with 2449 additions and 2466 deletions
+50 -9
View File
@@ -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>
)}
+6 -1
View File
@@ -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>
+41 -35
View File
@@ -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>
)
})}
+3 -4
View File
@@ -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">
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+11 -6
View File
@@ -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",
+6 -1
View File
@@ -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 limage et être recréés depuis leur projet déclaré.",
"dockerEngineManagedByOs": "Docker Engine {version} est géré par paquets et mis à jour par laction 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 nont 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",
+6 -1
View File
@@ -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 limmagine ed essere ricreati dal progetto dichiarato.",
"dockerEngineManagedByOs": "Docker Engine {version} è gestito dai pacchetti e viene aggiornato dallazione 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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+79 -15
View File
@@ -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.
+72
View File
@@ -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`.
+1
View File
@@ -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'),
+7 -21
View File
@@ -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:
+46 -9
View File
@@ -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
+59
View File
@@ -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 = [
+133 -5
View File
@@ -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)
+33 -20
View File
@@ -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
})