diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 061ed01a..de269101 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -212,6 +212,10 @@ class CommandDescriptionsTests(unittest.TestCase): # New LXC observation labels intentionally ship only EN/SK. # Model the bot's future output in temporary catalogs, just # like the other pending keys above; never rewrite locales. + messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) + # The notification history panel texts await the bot as well. + for key, value in english["settings"]["notifications"]["history"].items(): + messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp", "checkBeforeUpdate", "startBeforeCheck", "notCheckedYet", diff --git a/.github/scripts/tests/test_notification_history_panel.py b/.github/scripts/tests/test_notification_history_panel.py new file mode 100644 index 00000000..8399ec4b --- /dev/null +++ b/.github/scripts/tests/test_notification_history_panel.py @@ -0,0 +1,49 @@ +"""The notification settings show the delivery history the Monitor records, +the daily digest runs included, and never offer to erase it: Audit & Report +reads the same history to tell whether each channel delivers.""" +import json +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[3] +PAGE = ROOT / 'AppImage/components/notification-settings.tsx' +KEYS = ('title', 'description', 'empty', 'loadFailed', 'sent', 'notSent', + 'severityInfo', 'severityWarning', 'severityCritical') + + +class NotificationHistoryPanelTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.page = PAGE.read_text(encoding='utf-8') + + def test_the_panel_opens_and_lists_the_history(self): + self.assertIn('setShowHistory(open => !open)', self.page) + self.assertIn('history.map(entry =>', self.page) + self.assertIn('t("settings.notifications.history.title")', self.page) + + def test_the_history_is_read_without_a_success_flag(self): + loader = re.search(r'const loadHistory = useCallback\(async \(\) => \{(.*?)\n \}, \[\]\)', self.page, re.S) + self.assertIsNotNone(loader) + self.assertIn('Array.isArray(data.history)', loader.group(1)) + self.assertNotIn('data.success', loader.group(1)) + + def test_a_digest_run_and_a_failed_delivery_show_their_message(self): + self.assertIn('entry.event_type === "digest" ? entry.message', self.page) + self.assertIn('!sent ? entry.error_message', self.page) + + def test_the_page_never_erases_the_history(self): + self.assertNotIn('handleClearHistory', self.page) + self.assertIsNone(re.search(r'notifications/history["\'`][^)]*method:\s*["\']DELETE', self.page)) + + def test_the_texts_exist_in_english_and_spanish(self): + for language in ('en', 'es'): + catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8')) + history = catalog['settings']['notifications']['history'] + for key in KEYS: + with self.subTest(language=language, key=key): + self.assertTrue(history.get(key)) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_terminal_ctrl_keys.py b/.github/scripts/tests/test_terminal_ctrl_keys.py new file mode 100644 index 00000000..823b2168 --- /dev/null +++ b/.github/scripts/tests/test_terminal_ctrl_keys.py @@ -0,0 +1,31 @@ +"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so +a pasted text can be finished from a phone or a tablet.""" +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[3] +TERMINALS = { + 'terminal-panel': 'sendSequence', + 'script-terminal-modal': 'sendCommand', + 'lxc-terminal-modal': 'sendKey', +} + + +class TerminalCtrlKeysTests(unittest.TestCase): + def test_each_terminal_sends_ctrl_d(self): + for name, send in TERMINALS.items(): + source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8') + with self.subTest(terminal=name): + self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source) + self.assertIn('t("scriptTerminal.endOfInput")', source) + + def test_the_label_exists_in_english_and_spanish(self): + for language in ('en', 'es'): + catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8')) + with self.subTest(language=language): + self.assertTrue(catalog['scriptTerminal'].get('endOfInput')) + + +if __name__ == '__main__': + unittest.main() diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index 3afc8042..fba19ab1 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -688,6 +688,10 @@ export function LxcTerminalModal({ Ctrl+C {t("scriptTerminal.cancelInterrupt")} + sendKey("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendKey("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 621743a2..ac9733f1 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -18,7 +18,7 @@ import { Trash2, ChevronRight, TestTube2, Mail, Webhook, Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb, - Moon, Newspaper + Moon, Newspaper, History, ChevronDown } from "lucide-react" interface ChannelConfig { @@ -109,7 +109,8 @@ interface HistoryEntry { title: string severity: string sent_at: string - success: boolean + success: boolean | number + message?: string | null error_message: string | null } @@ -331,7 +332,8 @@ export function NotificationSettings() { const { resolvedTheme } = useTheme() const [config, setConfig] = useState(DEFAULT_CONFIG) const [status, setStatus] = useState(null) - const [history, setHistory] = useState([]) + const [history, setHistory] = useState(null) + const [historyError, setHistoryError] = useState(false) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) @@ -452,12 +454,12 @@ export function NotificationSettings() { const loadHistory = useCallback(async () => { try { - const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20") - if (data.success) { - setHistory(data.history || []) - } + const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20") + if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable") + setHistory(data.history) + setHistoryError(false) } catch { - // Ignore + setHistoryError(true) } }, []) @@ -1097,15 +1099,6 @@ export function NotificationSettings() { } } - const handleClearHistory = async () => { - try { - await fetchApi("/api/notifications/history", { method: "DELETE" }) - setHistory([]) - } catch { - // Ignore - } - } - // Maps each eye-button local key to the body shape the backend // expects for /api/notifications/reveal-secret. Centralised here so // the JSX call site stays a one-liner — `toggleSecret(key)`. @@ -2761,6 +2754,75 @@ export function NotificationSettings() { )} + {/* ── Notification history ── */} +
+ + {showHistory && ( + historyError ? ( +

{t("settings.notifications.history.loadFailed")}

+ ) : history === null ? ( +
+ ) : history.length === 0 ? ( +

{t("settings.notifications.history.empty")}

+ ) : ( +
+ {history.map(entry => { + const sent = Boolean(entry.success) + const severity = (entry.severity || "").toLowerCase() + const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null + return ( +
+ {sent ? ( + + ) : ( + + )} +
+ {entry.title || entry.event_type} + + {entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()} + + {detail && ( +

{detail}

+ )} +
+ {(severity === "critical" || severity === "warning" || severity === "info") && ( + + {severity === "critical" + ? t("settings.notifications.history.severityCritical") + : severity === "warning" + ? t("settings.notifications.history.severityWarning") + : t("settings.notifications.history.severityInfo")} + + )} +
+ ) + })} +
+ ) + )} +
)} diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 605ed58a..c993281d 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -918,6 +918,10 @@ const initMessage = { Ctrl+C {t("scriptTerminal.cancelInterrupt")}
+ sendCommand("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendCommand("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index 13ba67d3..d65ac01c 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -1038,6 +1038,10 @@ const handleClose = () => { Ctrl+C {t("scriptTerminal.cancelInterrupt")} + sendSequence("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendSequence("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 30631539..950a4a74 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1075,15 +1075,15 @@ "logs": "Logs" }, "actions": { - "start": "Start", - "shutdown": "Abschalten", + "start": "Starten", + "shutdown": "Herunterfahren", "reboot": "Neustart", "forceStop": "Stopp erzwingen", - "working": "Arbeiten...", - "startPending": "Beginnt...", - "shutdownPending": "Herunterfahren...", - "rebootPending": "Neustart...", - "stopPending": "Stoppen..." + "working": "Wird ausgeführt...", + "startPending": "Wird gestartet...", + "shutdownPending": "Wird heruntergefahren...", + "rebootPending": "Wird neu gestartet...", + "stopPending": "Wird gestoppt..." }, "confirm": { "forceStopTitle": "Stopp erzwingen", @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "Den benutzerdefinierten Aktualisierungsbefehl für „{name}“ entfernen?", "removeCustomCommandFailed": "Der benutzerdefinierte Aktualisierungsbefehl konnte nicht entfernt werden: {message}", "hideNoticeFailed": "Dieser Hinweis konnte nicht ausgeblendet werden: {message}", - "controlNotConfirmed": "Der Vorgang wurde nicht als abgeschlossen bestätigt.Überprüfen Sie den Gaststatus, bevor Sie es erneut versuchen." + "controlNotConfirmed": "Der Abschluss des Vorgangs wurde nicht bestätigt. Prüfen Sie den Status des Gasts, bevor Sie es erneut versuchen." }, "backupModal": { "title": "Sicherung {type} {vmid} ({name})", @@ -1460,15 +1460,15 @@ "checkStale": "Prüfung veraltet", "checkStaleHelp": "Dieses Ergebnis ist älter als 48 Stunden oder hat kein gültiges Prüfdatum. Führen Sie eine neue Prüfung durch, bevor Sie sich darauf verlassen.", "checkSystemPackages": "Systempakete prüfen", - "checkInProgress": "Überprüfung läuft", - "refreshCheckHelp": "Aktualisiert Paketindizes und prüft ausstehende Aktualisierungen, während der Container ausgeführt wird.Es ist nichts installiert.", - "notCheckedYet": "Updates wurden noch nicht überprüft.", - "checkStillRunning": "Die Prüfung läuft noch.Das Ergebnis wird nach Abschluss gespeichert.", - "checkBeforeUpdate": "Überprüfen Sie zuerst die Updates", - "startBeforeCheck": "Starten Sie den Container und suchen Sie nach Updates.Kein Abzeichen bedeutet nicht, dass es keine Updates gibt.", + "checkInProgress": "Prüfung läuft", + "refreshCheckHelp": "Aktualisiert die Paketindizes und prüft ausstehende Updates, während der Container läuft. Es wird nichts installiert.", + "notCheckedYet": "Updates wurden noch nicht geprüft.", + "checkStillRunning": "Die Prüfung läuft noch. Das Ergebnis wird nach Abschluss gespeichert.", + "checkBeforeUpdate": "Zuerst Updates prüfen", + "startBeforeCheck": "Starten Sie den Container und prüfen Sie dann die Updates. Ein fehlender Hinweis bedeutet nicht, dass keine Updates vorliegen.", "runCustomCommand": "Benutzerdefinierten Befehl ausführen", - "installedVersionUnverified": "Die installierte Version ist nicht bestätigt.Die Verfügbarkeit des Updates konnte nicht bestätigt werden.", - "versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt.Versionsinformationen sind nicht verfügbar oder unvollständig." + "installedVersionUnverified": "Die installierte Version ist nicht bestätigt. Ob ein Update verfügbar ist, konnte nicht bestätigt werden.", + "versionStatusUnknown": "Ob ein Update verfügbar ist, ist nicht bestätigt. Versionsinformationen fehlen oder sind unvollständig." }, "bulkUpdate": { "title": "Sammelaktualisierung", @@ -1494,9 +1494,9 @@ "deleteFailed": "Die Sammelaktualisierungskonfiguration konnte nicht entfernt werden.", "planFailed": "Die Sammelaktualisierung konnte nicht vorbereitet werden. Prüfen Sie die nicht verfügbaren Methoden.", "loadFailed": "Die Massenaktualisierungskonfiguration konnte nicht geladen werden.", - "osDockerScope": "Betriebssystemaktualisierungen enthalten auch Docker, wenn es als Systempaket installiert wird.", - "unverifiedTargetsHelp": "Die Verfügbarkeit von Updates für einige ausgewählte Artikel ist nicht bestätigt.Beim Starten werden die ausgewählten Update-Methoden auch ohne bestätigte neue Version ausgeführt.", - "dockerImageMethod": "Docker-Bild" + "osDockerScope": "Betriebssystem-Updates umfassen auch Docker, wenn es als Systempaket installiert ist.", + "unverifiedTargetsHelp": "Für einige ausgewählte Elemente ist nicht bestätigt, ob ein Update verfügbar ist. Beim Start werden die gewählten Update-Methoden auch ohne bestätigte neue Version ausgeführt.", + "dockerImageMethod": "Docker-Image" }, "appEditor": { "methodOciImage": "OCI-Image (von ProxMenux installiert)", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d9bd5866..e26859f4 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -2194,6 +2194,17 @@ "localPveApi": "The address cannot point to the local PVE API (localhost:8006/8007).", "metadataIp": "The link-local metadata IP cannot be used as a Gotify endpoint." }, + "history": { + "title": "Notification history", + "description": "The last 20 notifications sent, including each run of the daily digest, with the result of each one.", + "empty": "No notifications have been sent yet.", + "loadFailed": "The notification history could not be loaded.", + "sent": "Sent", + "notSent": "Not sent", + "severityInfo": "Info", + "severityWarning": "Warning", + "severityCritical": "Critical" + }, "customPromptInfo": { "title": "Custom prompt mode", "description": "Create your own AI prompt for ProxMenux Monitor notifications", @@ -5218,6 +5229,7 @@ "processing": "Processing...", "controlSequences": "Control Sequences", "cancelInterrupt": "Cancel/Interrupt", + "endOfInput": "End of input", "exitNano": "Exit (nano)", "searchHistory": "Search history", "clipboard": "Clipboard", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 63cef36a..da597ae4 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1080,8 +1080,8 @@ "reboot": "Reiniciar", "forceStop": "Parar", "working": "Procesando...", - "startPending": "Empezando...", - "shutdownPending": "Cerrando...", + "startPending": "Iniciando...", + "shutdownPending": "Apagando...", "rebootPending": "Reiniciando...", "stopPending": "Parando..." }, @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?", "removeCustomCommandFailed": "No se pudo eliminar el comando de actualización personalizado: {message}", "hideNoticeFailed": "No se pudo ocultar este aviso: {message}", - "controlNotConfirmed": "La operación no se ha confirmado completa.Verifique el estado del invitado antes de volver a intentarlo." + "controlNotConfirmed": "No se ha confirmado que la operación haya terminado. Compruebe el estado de la VM o del contenedor antes de volver a intentarlo." }, "backupModal": { "title": "Backup de {type} {vmid} ({name})", @@ -1454,21 +1454,21 @@ "postApplyNothingPending": "Nada pendiente — todo actualizado.", "postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.", "postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal.", - "checkNow": "Comprobar ahora", + "checkNow": "Comprobar actualizaciones", "checkReadOnlyHelp": "Lee el sistema operativo actual y los paquetes pendientes a partir de los índices existentes. No instala nada ni actualiza los índices de paquetes.", "checkCooldown": "Ya hay una comprobación en curso o se ha solicitado hace poco. Inténtalo de nuevo en {seconds} segundos.", "checkStale": "Comprobación caducada", "checkStaleHelp": "Este resultado tiene más de 48 horas o no tiene una fecha de comprobación válida. Lanza una nueva comprobación antes de fiarte de él.", - "checkSystemPackages": "comprobar los paquetes del sistema", - "checkInProgress": "Verificación en progreso", - "refreshCheckHelp": "actualiza los índices de paquetes y verifica las actualizaciones pendientes mientras el contenedor se está ejecutando.No hay nada instalado.", - "notCheckedYet": "Las actualizaciones aún no se han verificado.", - "checkStillRunning": "La verificación aún se está ejecutando.Su resultado se guardará cuando finalice.", - "checkBeforeUpdate": "Check updates first", - "startBeforeCheck": "inicie el contenedor y luego busque actualizaciones.No tener insignia no significa que no haya actualizaciones.", - "runCustomCommand": "ejecutar comando personalizado", - "installedVersionUnverified": "la versión instalada no está verificada.No se pudo confirmar la disponibilidad de la actualización.", - "versionStatusUnknown": "La disponibilidad de la actualización no está verificada.La información de la versión no está disponible o está incompleta." + "checkSystemPackages": "Comprobar paquetes del sistema", + "checkInProgress": "Comprobación en curso", + "refreshCheckHelp": "Renueva los índices de paquetes y comprueba las actualizaciones pendientes mientras el contenedor está en marcha. No instala nada.", + "notCheckedYet": "Las actualizaciones aún no se han comprobado.", + "checkStillRunning": "La comprobación sigue en curso. Su resultado se guardará al terminar.", + "checkBeforeUpdate": "Comprobar actualizaciones primero", + "startBeforeCheck": "Inicie el contenedor y después compruebe las actualizaciones. Que no aparezca el indicador no significa que no haya actualizaciones.", + "runCustomCommand": "Ejecutar comando personalizado", + "installedVersionUnverified": "La versión instalada no está verificada. No se pudo confirmar si hay una actualización disponible.", + "versionStatusUnknown": "No se ha verificado si hay una actualización disponible. La información de versión falta o está incompleta." }, "bulkUpdate": { "title": "Actualización en bloque", @@ -1477,7 +1477,7 @@ "notConfigured": "No configurada.", "configure": "Configurar", "edit": "Editar", - "apply": "Aplicar actualizaciones", + "apply": "Iniciar actualización masiva", "osTarget": "SO", "osRequired": "Obligatorio en una actualización en bloque.", "includesDependencies": "Incluye también las dependencias declaradas: {names}", @@ -1494,9 +1494,9 @@ "deleteFailed": "No se pudo eliminar la configuración de actualización en bloque.", "planFailed": "No se pudo preparar la actualización en bloque. Edite la configuración y revise los métodos no disponibles.", "loadFailed": "no se pudo cargar la configuración de actualización masiva.", - "osDockerScope": "las actualizaciones del sistema operativo también incluyen Docker cuando se instala como paquete del sistema.", - "unverifiedTargetsHelp": "la disponibilidad de actualizaciones para algunos elementos seleccionados no está verificada.Al iniciar se ejecutarán los métodos de actualización seleccionados incluso sin una nueva versión confirmada.", - "dockerImageMethod": "imagen Docker" + "osDockerScope": "Las actualizaciones del sistema operativo también incluyen Docker cuando está instalado como paquete del sistema.", + "unverifiedTargetsHelp": "No se ha verificado si hay actualización para algunos de los elementos seleccionados. Al iniciar se ejecutarán los métodos de actualización elegidos aunque no haya una versión nueva confirmada.", + "dockerImageMethod": "Imagen Docker" }, "appEditor": { "dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores", @@ -2195,6 +2195,17 @@ "localPveApi": "La dirección no puede apuntar a la API PVE local (localhost:8006/8007).", "metadataIp": "La IP de metadatos del enlace local no se puede utilizar como punto final de Gotify." }, + "history": { + "title": "Historial de notificaciones", + "description": "Las últimas 20 notificaciones enviadas, incluida cada ejecución del resumen diario, con el resultado de cada una.", + "empty": "Todavía no se ha enviado ninguna notificación.", + "loadFailed": "No se pudo cargar el historial de notificaciones.", + "sent": "Enviada", + "notSent": "No enviada", + "severityInfo": "Info", + "severityWarning": "Advertencia", + "severityCritical": "Crítico" + }, "customPromptInfo": { "title": "Modo de aviso personalizado", "description": "Cree su propio mensaje de IA para las notificaciones de ProxMenux Monitor", @@ -5219,6 +5230,7 @@ "processing": "Tratamiento...", "controlSequences": "Secuencias de control", "cancelInterrupt": "Cancelar/Interrumpir", + "endOfInput": "Fin de la entrada", "exitNano": "Salir (nano)", "searchHistory": "Historial de búsqueda", "clipboard": "Portapapeles", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 4e0f7781..964c4815 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1075,15 +1075,15 @@ "logs": "Logs" }, "actions": { - "start": "Commencer", - "shutdown": "Fermer", + "start": "Démarrer", + "shutdown": "Éteindre", "reboot": "Redémarrer", "forceStop": "Forcer l'arrêt", - "working": "Fonctionnement...", - "startPending": "Démarrer...", - "shutdownPending": "Arrêter...", + "working": "En cours...", + "startPending": "Démarrage...", + "shutdownPending": "Extinction...", "rebootPending": "Redémarrage...", - "stopPending": "Arrêter..." + "stopPending": "Arrêt..." }, "confirm": { "forceStopTitle": "Forcer l'arrêt", @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?", "removeCustomCommandFailed": "Impossible de supprimer la commande de mise à jour personnalisée : {message}", "hideNoticeFailed": "Impossible de masquer cet avis : {message}", - "controlNotConfirmed": "L'opération n'a pas été confirmée comme étant terminée.Vérifiez le statut de l'invité avant de réessayer." + "controlNotConfirmed": "La fin de l'opération n'a pas été confirmée. Vérifiez l'état de l'invité avant de réessayer." }, "backupModal": { "title": "Sauvegarde {type} {vmid} ({name})", @@ -1459,16 +1459,16 @@ "checkCooldown": "Une vérification est déjà en cours ou a été demandée récemment. Réessayez dans {seconds} secondes.", "checkStale": "Vérification obsolète", "checkStaleHelp": "Ce résultat date de plus de 48 heures ou n’a pas de date de vérification valide. Lancez une nouvelle vérification avant de vous y fier.", - "checkSystemPackages": "Vérifier les packages système", + "checkSystemPackages": "Vérifier les paquets système", "checkInProgress": "Vérification en cours", - "refreshCheckHelp": "actualise les index des packages et vérifie les mises à jour en attente pendant l'exécution du conteneur.Rien n'est installé.", + "refreshCheckHelp": "Actualise les index de paquets et vérifie les mises à jour en attente pendant que le conteneur est en cours d'exécution. Rien n'est installé.", "notCheckedYet": "Les mises à jour n'ont pas encore été vérifiées.", - "checkStillRunning": "Le contrôle est toujours en cours.Son résultat sera enregistré une fois terminé.", - "checkBeforeUpdate": "Vérifiez d'abord les mises à jour", - "startBeforeCheck": "démarrez le conteneur, puis recherchez les mises à jour.L'absence de badge ne signifie pas qu'il n'y a pas de mises à jour.", + "checkStillRunning": "La vérification est toujours en cours. Son résultat sera enregistré à la fin.", + "checkBeforeUpdate": "Vérifier d'abord les mises à jour", + "startBeforeCheck": "Démarrez le conteneur, puis vérifiez les mises à jour. L'absence de badge ne signifie pas qu'il n'y a pas de mises à jour.", "runCustomCommand": "Exécuter une commande personnalisée", - "installedVersionUnverified": "la version installée n’est pas vérifiée.La disponibilité de la mise à jour n'a pas pu être confirmée.", - "versionStatusUnknown": "la disponibilité de la mise à jour n'est pas vérifiée.Les informations sur la version sont indisponibles ou incomplètes." + "installedVersionUnverified": "La version installée n'est pas vérifiée. La disponibilité d'une mise à jour n'a pas pu être confirmée.", + "versionStatusUnknown": "La disponibilité d'une mise à jour n'est pas vérifiée. Les informations de version sont indisponibles ou incomplètes." }, "bulkUpdate": { "title": "Mise à jour groupée", @@ -1494,9 +1494,9 @@ "deleteFailed": "Impossible de supprimer la configuration de mise à jour groupée.", "planFailed": "Impossible de préparer la mise à jour groupée. Vérifiez les méthodes indisponibles.", "loadFailed": "Impossible de charger la configuration de la mise à jour groupée.", - "osDockerScope": "Les mises à jour du système d'exploitation incluent également Docker lorsqu'il est installé en tant que package système.", - "unverifiedTargetsHelp": "la disponibilité des mises à jour pour certains éléments sélectionnés n'est pas vérifiée.Le démarrage exécutera les méthodes de mise à jour sélectionnées même sans nouvelle version confirmée.", - "dockerImageMethod": "image Docker" + "osDockerScope": "Les mises à jour du système incluent aussi Docker lorsqu'il est installé comme paquet système.", + "unverifiedTargetsHelp": "La disponibilité des mises à jour n'est pas vérifiée pour certains éléments sélectionnés. Le lancement exécutera les méthodes de mise à jour choisies même sans nouvelle version confirmée.", + "dockerImageMethod": "Image Docker" }, "appEditor": { "methodOciImage": "Image OCI (installée par ProxMenux)", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 68b71f61..51618da8 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1080,10 +1080,10 @@ "reboot": "Riavvia", "forceStop": "Forza arresto", "working": "Operazione in corso...", - "startPending": "A partire...", + "startPending": "Avvio...", "shutdownPending": "Spegnimento...", "rebootPending": "Riavvio...", - "stopPending": "Fermarsi..." + "stopPending": "Arresto..." }, "confirm": { "forceStopTitle": "Forza arresto", @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "Rimuovere il comando di aggiornamento personalizzato per \"{name}\"?", "removeCustomCommandFailed": "Impossibile rimuovere il comando di aggiornamento personalizzato: {message}", "hideNoticeFailed": "Impossibile nascondere questo avviso: {message}", - "controlNotConfirmed": "L'operazione non è stata confermata completata.Controlla lo stato dell'ospite prima di riprovare." + "controlNotConfirmed": "Il completamento dell'operazione non è stato confermato. Controlla lo stato del guest prima di riprovare." }, "backupModal": { "title": "Backup {type} {vmid} ({name})", @@ -1459,16 +1459,16 @@ "checkCooldown": "Un controllo è già in corso o è stato richiesto di recente. Riprovare tra {seconds} secondi.", "checkStale": "Controllo obsoleto", "checkStaleHelp": "Questo risultato ha più di 48 ore o non ha una data di controllo valida. Eseguire un nuovo controllo prima di farvi affidamento.", - "checkSystemPackages": "controlla i pacchetti di sistema", - "checkInProgress": "controllo in corso", - "refreshCheckHelp": "aggiorna gli indici dei pacchetti e controlla gli aggiornamenti in sospeso mentre il contenitore è in esecuzione.Non è installato nulla.", - "notCheckedYet": "gli aggiornamenti non sono stati ancora controllati.", - "checkStillRunning": "Il controllo è ancora in corso.Il risultato verrà salvato al termine.", - "checkBeforeUpdate": "controlla prima gli aggiornamenti", - "startBeforeCheck": "avvia il contenitore, quindi controlla gli aggiornamenti.Nessun badge non significa che non ci sono aggiornamenti.", - "runCustomCommand": "esegui il comando personalizzato", - "installedVersionUnverified": "la versione installata non è verificata.Non è stato possibile confermare la disponibilità dell'aggiornamento.", - "versionStatusUnknown": "la disponibilità dell'aggiornamento non è verificata.Le informazioni sulla versione non sono disponibili o sono incomplete." + "checkSystemPackages": "Controlla pacchetti di sistema", + "checkInProgress": "Controllo in corso", + "refreshCheckHelp": "Aggiorna gli indici dei pacchetti e controlla gli aggiornamenti in sospeso mentre il container è in esecuzione. Non viene installato nulla.", + "notCheckedYet": "Gli aggiornamenti non sono ancora stati controllati.", + "checkStillRunning": "Il controllo è ancora in corso. Il risultato verrà salvato al termine.", + "checkBeforeUpdate": "Controlla prima gli aggiornamenti", + "startBeforeCheck": "Avvia il container, poi controlla gli aggiornamenti. L'assenza del badge non significa che non ci siano aggiornamenti.", + "runCustomCommand": "Esegui comando personalizzato", + "installedVersionUnverified": "La versione installata non è verificata. Non è stato possibile confermare la disponibilità di un aggiornamento.", + "versionStatusUnknown": "La disponibilità di un aggiornamento non è verificata. Le informazioni sulla versione non sono disponibili o sono incomplete." }, "bulkUpdate": { "title": "Aggiornamento in blocco", @@ -1494,9 +1494,9 @@ "deleteFailed": "Impossibile rimuovere la configurazione di aggiornamento in blocco.", "planFailed": "Impossibile preparare l’aggiornamento in blocco. Modifica la configurazione e controlla i metodi non disponibili.", "loadFailed": "Impossibile caricare la configurazione dell’aggiornamento in blocco.", - "osDockerScope": "gli aggiornamenti del sistema operativo includono anche Docker quando è installato come pacchetto di sistema.", - "unverifiedTargetsHelp": "la disponibilità dell'aggiornamento per alcuni articoli selezionati non è verificata.L'avvio eseguirà i metodi di aggiornamento selezionati anche senza una nuova versione confermata.", - "dockerImageMethod": "immagine Docker" + "osDockerScope": "Gli aggiornamenti del sistema operativo includono anche Docker quando è installato come pacchetto di sistema.", + "unverifiedTargetsHelp": "La disponibilità di aggiornamenti per alcuni elementi selezionati non è verificata. L'avvio eseguirà i metodi di aggiornamento scelti anche senza una nuova versione confermata.", + "dockerImageMethod": "Immagine Docker" }, "appEditor": { "methodOciImage": "Immagine OCI (installata da ProxMenux)", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 3e28daae..3fa47033 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1075,12 +1075,12 @@ "logs": "Logs" }, "actions": { - "start": "Começar", + "start": "Iniciar", "shutdown": "Desligar", - "reboot": "Reinício", + "reboot": "Reiniciar", "forceStop": "Forçar parada", - "working": "Trabalhando...", - "startPending": "Começando...", + "working": "Processando...", + "startPending": "Iniciando...", "shutdownPending": "Desligando...", "rebootPending": "Reiniciando...", "stopPending": "Parando..." @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?", "removeCustomCommandFailed": "não foi possível remover o comando de atualização personalizado: {message}", "hideNoticeFailed": "Não foi possível ocultar este aviso: {message}", - "controlNotConfirmed": "A operação não foi confirmada como concluída.Verifique o status do convidado antes de tentar novamente." + "controlNotConfirmed": "A conclusão da operação não foi confirmada. Verifique o estado do convidado antes de tentar novamente." }, "backupModal": { "title": "Backup {type} {vmid} ({name})", @@ -1459,16 +1459,16 @@ "checkCooldown": "Já há uma verificação em execução ou pedida recentemente. Tente novamente dentro de {seconds} segundos.", "checkStale": "Verificação desatualizada", "checkStaleHelp": "Este resultado tem mais de 48 horas ou não tem uma data de verificação válida. Execute uma nova verificação antes de confiar nele.", - "checkSystemPackages": "Verifique os pacotes do sistema", - "checkInProgress": "verificação em andamento", - "refreshCheckHelp": "atualiza índices de pacotes e verifica atualizações pendentes enquanto o contêiner está em execução.Nada está instalado.", + "checkSystemPackages": "Verificar pacotes do sistema", + "checkInProgress": "Verificação em andamento", + "refreshCheckHelp": "Atualiza os índices de pacotes e verifica as atualizações pendentes enquanto o contêiner está em execução. Nada é instalado.", "notCheckedYet": "As atualizações ainda não foram verificadas.", - "checkStillRunning": "A verificação ainda está em execução.Seu resultado será salvo quando terminar.", - "checkBeforeUpdate": "verifique as atualizações primeiro", - "startBeforeCheck": "inicie o contêiner e verifique se há atualizações.Nenhum selo não significa que não haja atualizações.", + "checkStillRunning": "A verificação ainda está em execução. O resultado será salvo quando terminar.", + "checkBeforeUpdate": "Verificar atualizações primeiro", + "startBeforeCheck": "Inicie o contêiner e depois verifique as atualizações. A ausência do selo não significa que não haja atualizações.", "runCustomCommand": "Executar comando personalizado", - "installedVersionUnverified": "a versão instalada não foi verificada.A disponibilidade da atualização não pôde ser confirmada.", - "versionStatusUnknown": "a disponibilidade da atualização não foi verificada.As informações da versão estão indisponíveis ou incompletas." + "installedVersionUnverified": "A versão instalada não foi verificada. Não foi possível confirmar se há uma atualização disponível.", + "versionStatusUnknown": "A disponibilidade de atualização não foi verificada. As informações de versão estão indisponíveis ou incompletas." }, "bulkUpdate": { "title": "Atualização em bloco", @@ -1494,9 +1494,9 @@ "deleteFailed": "Não foi possível remover a configuração de atualização em bloco.", "planFailed": "Não foi possível preparar a atualização em bloco. Reveja os métodos indisponíveis.", "loadFailed": "não foi possível carregar a configuração de atualização em massa.", - "osDockerScope": "as atualizações do sistema operacional também incluem Docker quando instalado como um pacote de sistema.", - "unverifiedTargetsHelp": "a disponibilidade de atualização para alguns itens selecionados não foi verificada.A inicialização executará os métodos de atualização selecionados mesmo sem uma nova versão confirmada.", - "dockerImageMethod": "imagem Docker" + "osDockerScope": "As atualizações do sistema operacional também incluem o Docker quando ele está instalado como pacote do sistema.", + "unverifiedTargetsHelp": "A disponibilidade de atualização de alguns itens selecionados não foi verificada. Ao iniciar, os métodos de atualização escolhidos serão executados mesmo sem uma nova versão confirmada.", + "dockerImageMethod": "Imagem Docker" }, "appEditor": { "methodOciImage": "Imagem OCI (instalada pelo ProxMenux)", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 918d7e0f..ffed7569 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1075,12 +1075,12 @@ "logs": "Loggar" }, "actions": { - "start": "Start", - "shutdown": "Stängning", + "start": "Starta", + "shutdown": "Stäng av", "reboot": "Starta om", "forceStop": "Tvinga stopp", - "working": "Arbetssätt...", - "startPending": "Börjar...", + "working": "Arbetar...", + "startPending": "Startar...", "shutdownPending": "Stänger av...", "rebootPending": "Startar om...", "stopPending": "Stoppar..." @@ -1133,7 +1133,7 @@ "removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?", "removeCustomCommandFailed": "Kunde inte ta bort det anpassade uppdateringskommandot: {message}", "hideNoticeFailed": "Kunde inte dölja detta meddelande: {message}", - "controlNotConfirmed": "Operationen har inte bekräftats slutförd.Kontrollera gäststatusen innan du försöker igen." + "controlNotConfirmed": "Det har inte bekräftats att åtgärden är slutförd. Kontrollera gästens status innan du försöker igen." }, "backupModal": { "title": "Säkerhetskopiera {type} {vmid} ({name})", @@ -1460,15 +1460,15 @@ "checkStale": "Föråldrad kontroll", "checkStaleHelp": "Detta resultat är äldre än 48 timmar eller saknar ett giltigt kontrolldatum. Kör en ny kontroll innan du litar på det.", "checkSystemPackages": "Kontrollera systempaket", - "checkInProgress": "Incheckning pågår", - "refreshCheckHelp": "Uppdaterar paketindex och kontrollerar väntande uppdateringar medan behållaren körs.Inget är installerat.", + "checkInProgress": "Kontroll pågår", + "refreshCheckHelp": "Uppdaterar paketindex och kontrollerar väntande uppdateringar medan behållaren körs. Inget installeras.", "notCheckedYet": "Uppdateringar har inte kontrollerats ännu.", - "checkStillRunning": "Kontrollen körs fortfarande.Resultatet kommer att sparas när det är klart.", - "checkBeforeUpdate": "Kontrollera uppdateringarna först", - "startBeforeCheck": "Starta behållaren och leta sedan efter uppdateringar.Inget märke betyder inte att det inte finns några uppdateringar.", + "checkStillRunning": "Kontrollen pågår fortfarande. Resultatet sparas när den är klar.", + "checkBeforeUpdate": "Kontrollera uppdateringar först", + "startBeforeCheck": "Starta behållaren och kontrollera sedan uppdateringar. Att ett märke saknas betyder inte att det inte finns uppdateringar.", "runCustomCommand": "Kör anpassat kommando", - "installedVersionUnverified": "Den installerade versionen är overifierad.Uppdateringstillgänglighet kunde inte bekräftas.", - "versionStatusUnknown": "Uppdateringstillgängligheten är overifierad.Versionsinformation är inte tillgänglig eller ofullständig." + "installedVersionUnverified": "Den installerade versionen är inte verifierad. Det gick inte att bekräfta om en uppdatering finns.", + "versionStatusUnknown": "Det är inte verifierat om en uppdatering finns. Versionsinformationen saknas eller är ofullständig." }, "bulkUpdate": { "title": "Gruppuppdatering", @@ -1494,9 +1494,9 @@ "deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.", "planFailed": "Det gick inte att förbereda gruppuppdateringen. Kontrollera otillgängliga metoder.", "loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen.", - "osDockerScope": "OS-uppdateringar inkluderar även Docker när det är installerat som ett systempaket.", - "unverifiedTargetsHelp": "Uppdateringstillgängligheten för vissa valda objekt är overifierad.Om du startar kommer de valda uppdateringsmetoderna att köras även utan en bekräftad ny version.", - "dockerImageMethod": "Docker bild" + "osDockerScope": "OS-uppdateringar omfattar även Docker när det är installerat som systempaket.", + "unverifiedTargetsHelp": "För vissa valda objekt är det inte verifierat om en uppdatering finns. Vid start körs de valda uppdateringsmetoderna även utan en bekräftad ny version.", + "dockerImageMethod": "Docker-avbildning" }, "appEditor": { "methodOciImage": "OCI-avbild (installerad av ProxMenux)", diff --git a/scripts/global/pci_passthrough_helpers.sh b/scripts/global/pci_passthrough_helpers.sh index 17b11601..5937d457 100644 --- a/scripts/global/pci_passthrough_helpers.sh +++ b/scripts/global/pci_passthrough_helpers.sh @@ -456,6 +456,34 @@ PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE="${PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE:- # remove them when migrating a host that ran the older init-top hook. PROXMENUX_VFIO_BIND_LEGACY_HOOK="${PROXMENUX_VFIO_BIND_LEGACY_HOOK:-${PROXMENUX_ETC_ROOT}/initramfs-tools/scripts/init-top/proxmenux-vfio-bind}" +# The udev rule the state file asks for. +_proxmenux_vfio_bind_render_udev_rule() { + { + echo "# ProxMenux: per-BDF VFIO driver override" + echo "# Auto-generated from $PROXMENUX_VFIO_BIND_STATE" + echo "# DO NOT EDIT MANUALLY — regenerated by add_gpu_vm.sh / switch_gpu_mode*.sh" + while IFS= read -r bdf; do + [[ -z "$bdf" ]] && continue + [[ "$bdf" == \#* ]] && continue + # KERNEL match expects the "0000:XX:YY.Z" form + local full="$bdf" + [[ "$full" != 0000:* ]] && full="0000:${full}" + echo "SUBSYSTEM==\"pci\", KERNEL==\"${full}\", ATTR{driver_override}=\"vfio-pci\"" + done < "$PROXMENUX_VFIO_BIND_STATE" + } +} + +# True when the udev rule on disk is the one the state file asks for, so a +# rule removed or edited by hand is written again on the next run. +_proxmenux_vfio_bind_udev_rule_current() { + if [[ ! -s "$PROXMENUX_VFIO_BIND_STATE" ]]; then + [[ ! -e "$PROXMENUX_VFIO_BIND_UDEV_RULE" ]] + return + fi + [[ -f "$PROXMENUX_VFIO_BIND_UDEV_RULE" ]] \ + && cmp -s <(_proxmenux_vfio_bind_render_udev_rule) "$PROXMENUX_VFIO_BIND_UDEV_RULE" +} + _proxmenux_vfio_bind_write_udev_rule() { # Always nuke the obsolete init-top hook from earlier attempts (if it # still exists) so a stale copy in initramfs can't run alongside the @@ -470,19 +498,7 @@ _proxmenux_vfio_bind_write_udev_rule() { fi mkdir -p "$(dirname "$PROXMENUX_VFIO_BIND_UDEV_RULE")" - { - echo "# ProxMenux: per-BDF VFIO driver override" - echo "# Auto-generated from $PROXMENUX_VFIO_BIND_STATE" - echo "# DO NOT EDIT MANUALLY — regenerated by add_gpu_vm.sh / switch_gpu_mode*.sh" - while IFS= read -r bdf; do - [[ -z "$bdf" ]] && continue - [[ "$bdf" == \#* ]] && continue - # KERNEL match expects the "0000:XX:YY.Z" form - local full="$bdf" - [[ "$full" != 0000:* ]] && full="0000:${full}" - echo "SUBSYSTEM==\"pci\", KERNEL==\"${full}\", ATTR{driver_override}=\"vfio-pci\"" - done < "$PROXMENUX_VFIO_BIND_STATE" - } > "$PROXMENUX_VFIO_BIND_UDEV_RULE" + _proxmenux_vfio_bind_render_udev_rule > "$PROXMENUX_VFIO_BIND_UDEV_RULE" udevadm control --reload-rules >/dev/null 2>&1 || true } @@ -549,7 +565,7 @@ _proxmenux_vfio_bind_add_bdfs() { changed=true fi done - if $changed; then + if $changed || ! _proxmenux_vfio_bind_udev_rule_current; then _proxmenux_vfio_bind_write_udev_rule _proxmenux_nvidia_vfio_policy_sync || true _proxmenux_mark_host_config_changed diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index fa0d01b2..7e0dca70 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -96,20 +96,29 @@ def evaluate(suite, apply=False): pve = [] ceph = [] debian = False + wrong_pve = [] + wrong_ceph = [] for path, index, row in entries: if not row['Enabled'] or 'deb' not in row['Types']: continue components = set(row['Components']) if components & PVE_CHANNELS: if suite not in row['Suites']: - raise ValueError('PVE repository suite does not match the installed version; no repository changed.') + wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})") pve.append((path, index, row)) if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS: debian = True if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): if suite not in row['Suites']: - raise ValueError('Enterprise Ceph repository suite does not match this PVE version; correct it in the Proxmox repository UI before switching.') + wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})") ceph.append((path, index, row)) + # Name the stale sources, never their URIs: those can carry credentials. + if wrong_pve: + raise ValueError(f"This host runs Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: " + f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.") + if wrong_ceph: + raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: " + f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.") if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve): return 'preserve' diff --git a/scripts/gpu_tpu/add_gpu_vm.sh b/scripts/gpu_tpu/add_gpu_vm.sh index a1b44238..bbbb194a 100644 --- a/scripts/gpu_tpu/add_gpu_vm.sh +++ b/scripts/gpu_tpu/add_gpu_vm.sh @@ -339,6 +339,11 @@ evaluate_host_reboot_requirement() { needs_change=true fi done + # The state alone is not enough: its udev rule may have been removed. + if declare -F _proxmenux_vfio_bind_udev_rule_current >/dev/null 2>&1 \ + && ! _proxmenux_vfio_bind_udev_rule_current; then + needs_change=true + fi _file_has_exact_line "softdep nvidia pre: vfio-pci" "$vfio_conf" || needs_change=true _file_has_exact_line "softdep nvidia_drm pre: vfio-pci" "$vfio_conf" || needs_change=true _file_has_exact_line "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" || needs_change=true diff --git a/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh b/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh index 38e2e5dd..fdb7c331 100644 --- a/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh +++ b/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh @@ -67,6 +67,28 @@ grep -q 'ids=8086:7ad0' "$PROXMENUX_VFIO_CONF" ! grep -qi '10de:228b' "$PROXMENUX_VFIO_CONF" _proxmenux_all_nvidia_in_vfio [[ -f "$PROXMENUX_NVIDIA_VFIO_BLACKLIST" ]] +_proxmenux_vfio_bind_udev_rule_current + +# A rule removed by hand while the state still lists the GPU is written again +# when the GPU is added once more, and the host is marked for a reboot. +rm -f "$PROXMENUX_VFIO_BIND_UDEV_RULE" +! _proxmenux_vfio_bind_udev_rule_current +HOST_CONFIG_CHANGED=false +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 0000:01:00.1 +grep -qF 'KERNEL=="0000:01:00.0"' "$PROXMENUX_VFIO_BIND_UDEV_RULE" +grep -qF 'KERNEL=="0000:02:00.1"' "$PROXMENUX_VFIO_BIND_UDEV_RULE" +_proxmenux_vfio_bind_udev_rule_current +[[ "$HOST_CONFIG_CHANGED" == true ]] + +# A rule that already matches the state is left alone. +HOST_CONFIG_CHANGED=false +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 +[[ "$HOST_CONFIG_CHANGED" == false ]] + +# A rule edited by hand is replaced by the one the state asks for. +echo '# edited' >> "$PROXMENUX_VFIO_BIND_UDEV_RULE" +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 +! grep -qF '# edited' "$PROXMENUX_VFIO_BIND_UDEV_RULE" # Returning only one GPU to the host must keep the other exact BDF in VFIO, # remove the global NVIDIA blacklist, retain NVIDIA softdeps, and restore any diff --git a/tests/test_repository_preservation.py b/tests/test_repository_preservation.py index 839b79fe..053da743 100644 --- a/tests/test_repository_preservation.py +++ b/tests/test_repository_preservation.py @@ -468,6 +468,25 @@ class RepositoryPolicyTest(unittest.TestCase): self.run_policy(apply=True) self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls)) + def test_wrong_suite_names_each_stale_source_and_where_to_fix_it(self): + self.repositories = fixture( + ('/etc/apt/sources.list.d/pve.list', [repo('pve-no-subscription')]), + ('/etc/apt/sources.list.d/pve-install-repo.list', + [repo('pve-no-subscription', uri='https://user:secret@mirror.example/debian/pve', suite='bookworm')]), + ('/etc/apt/sources.list.d/debian.sources', [repo('main', uri='https://deb.example/debian')]), + ) + with self.assertRaises(ValueError) as error: + self.run_policy(apply=True) + message = str(error.exception) + self.assertIn('/etc/apt/sources.list.d/pve-install-repo.list', message) + self.assertIn('pve-no-subscription', message) + self.assertIn('suite bookworm', message) + self.assertIn('runs Proxmox VE on trixie', message) + self.assertIn('Node > Updates > Repositories', message) + self.assertNotIn('/etc/apt/sources.list.d/pve.list', message) + self.assertNotIn('secret', message) + self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls)) + def test_ceph_enterprise_mirror_is_disabled_without_choosing_ceph_channel(self): self.repositories['files'].append({'path': '/etc/apt/sources.list.d/storage.list', 'repositories': [repo('enterprise', uri='https://mirror.example/ceph-squid')]}) diff --git a/web/messages/en/docs/monitor/notifications.json b/web/messages/en/docs/monitor/notifications.json index 656991a1..b6758553 100644 --- a/web/messages/en/docs/monitor/notifications.json +++ b/web/messages/en/docs/monitor/notifications.json @@ -441,7 +441,7 @@ "history": { "heading": "History", "body1": "Every dispatch attempt the dispatcher actually performs is recorded in the notification_history SQLite table. Each row stores the timestamp (sent_at), channel, event type, severity, title, rendered message body, a success flag and — when the send failed — the error returned by the provider in error_message. Burst-aggregated events appear as a single row with the burst_* event type. Events suppressed by the cooldown stage are not logged: they never become a dispatch attempt.", - "body2": "The History tab inside Settings → Notifications shows the last 20 entries and has a single Clear button that wipes the table.", + "body2": "Settings → Notifications has a Notification history section, closed by default, with the last 20 entries: the result of each send, its channel and date, the error when it failed and, for each daily digest run, whether it had something to send. The page does not offer to erase it: Audit & Report reads the same history to check the delivery of each channel.", "body3": "The same data is exposed at GET /api/notifications/history with optional limit, offset, severity and channel query parameters, and can be cleared with DELETE /api/notifications/history." }, "api": { diff --git a/web/messages/es/docs/monitor/notifications.json b/web/messages/es/docs/monitor/notifications.json index a0d409ca..85089fa0 100644 --- a/web/messages/es/docs/monitor/notifications.json +++ b/web/messages/es/docs/monitor/notifications.json @@ -441,7 +441,7 @@ "history": { "heading": "Historial", "body1": "Cada intento de despacho que el dispatcher realmente ejecuta se registra en la tabla SQLite notification_history. Cada fila guarda el timestamp (sent_at), canal, tipo de evento, severidad, título, cuerpo de mensaje renderizado, un flag success y — cuando el envío falló — el error devuelto por el proveedor en error_message. Los eventos agregados en ráfaga aparecen como una única fila con el tipo de evento burst_*. Los eventos suprimidos por la etapa de cooldown no se loguean: nunca se convierten en un intento de despacho.", - "body2": "La pestaña History dentro de Settings → Notifications muestra las últimas 20 entradas y tiene un único botón Clear que borra la tabla.", + "body2": "Settings → Notifications tiene una sección Historial de notificaciones, cerrada por defecto, con las últimas 20 entradas: el resultado de cada envío, su canal y su fecha, el error cuando falló y, en cada ejecución del resumen diario, si tenía algo que enviar. La página no ofrece borrarlo: Audit & Report lee ese mismo historial para comprobar la entrega de cada canal.", "body3": "Los mismos datos se exponen en GET /api/notifications/history con parámetros opcionales limit, offset, severity y channel, y se pueden borrar con DELETE /api/notifications/history." }, "api": {