replication failure notifications

This commit is contained in:
MacRimi
2026-09-02 16:28:03 +02:00
parent d10acbc895
commit e7b69dae91
15 changed files with 544 additions and 12 deletions
+3
View File
@@ -512,6 +512,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
} }
return t("vmLxc.appEditor.upstreamErrorNetwork", { detail }) return t("vmLxc.appEditor.upstreamErrorNetwork", { detail })
} }
if (lower.includes("github rate limited")) {
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
}
return msg return msg
} }
+177 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react" import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings" import { NotificationSettings } from "./notification-settings"
@@ -392,6 +392,17 @@ export function Settings() {
const [loadingInterfaces, setLoadingInterfaces] = useState(true) const [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState<string | null>(null) const [savingInterface, setSavingInterface] = useState<string | null>(null)
// Optional GitHub API authentication for app release/tag checks. The
// backend only returns whether a token exists; the secret itself never
// leaves the host after it has been saved.
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false)
const [githubTokenLoading, setGithubTokenLoading] = useState(true)
const [githubTokenEditMode, setGithubTokenEditMode] = useState(false)
const [githubTokenDraft, setGithubTokenDraft] = useState("")
const [githubTokenSaving, setGithubTokenSaving] = useState(false)
const [githubTokenSaved, setGithubTokenSaved] = useState(false)
const [githubTokenError, setGithubTokenError] = useState("")
// Active Suppressions panel — lists every error currently dismissed // Active Suppressions panel — lists every error currently dismissed
// (time-limited or permanent) so the user can re-enable individual // (time-limited or permanent) so the user can re-enable individual
// alerts. Mirrors what /api/health/full returns under `dismissed`. // alerts. Mirrors what /api/health/full returns under `dismissed`.
@@ -451,6 +462,63 @@ export function Settings() {
} }
} }
const loadGithubTokenStatus = async () => {
setGithubTokenLoading(true)
try {
const data = await fetchApi<{ configured: boolean }>("/api/apps/github-token")
setGithubTokenConfigured(!!data.configured)
setGithubTokenError("")
} catch (err) {
console.error("Failed to load GitHub API token status:", err)
setGithubTokenError(t("settings.githubApi.loadFailed"))
} finally {
setGithubTokenLoading(false)
}
}
const saveGithubToken = async () => {
const token = githubTokenDraft.trim()
if (!token) return
setGithubTokenSaving(true)
setGithubTokenError("")
try {
await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
method: "PUT",
body: JSON.stringify({ token }),
})
setGithubTokenConfigured(true)
setGithubTokenDraft("")
setGithubTokenEditMode(false)
setGithubTokenSaved(true)
window.setTimeout(() => setGithubTokenSaved(false), 2500)
} catch (err) {
console.error("Failed to save GitHub API token:", err)
setGithubTokenError(t("settings.githubApi.saveFailed"))
} finally {
setGithubTokenSaving(false)
}
}
const removeGithubToken = async () => {
setGithubTokenSaving(true)
setGithubTokenError("")
try {
await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
method: "DELETE",
})
setGithubTokenConfigured(false)
setGithubTokenDraft("")
setGithubTokenEditMode(false)
setGithubTokenSaved(true)
window.setTimeout(() => setGithubTokenSaved(false), 2500)
} catch (err) {
console.error("Failed to remove GitHub API token:", err)
setGithubTokenError(t("settings.githubApi.removeFailed"))
} finally {
setGithubTokenSaving(false)
}
}
useEffect(() => { useEffect(() => {
loadProxmenuxTools() loadProxmenuxTools()
getUnitsSettings() getUnitsSettings()
@@ -459,6 +527,7 @@ export function Settings() {
loadActiveSuppressions() loadActiveSuppressions()
loadNetworkInterfaces() loadNetworkInterfaces()
loadSnippetsStorage() loadSnippetsStorage()
loadGithubTokenStatus()
}, []) }, [])
// Refresh the Active Suppressions list whenever: // Refresh the Active Suppressions list whenever:
@@ -1803,6 +1872,113 @@ export function Settings() {
is re-enabled). */} is re-enabled). */}
<LxcUpdateDetection /> <LxcUpdateDetection />
{/* GitHub API — optional authentication for app upstream checks. */}
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Github className="h-5 w-5 text-foreground" />
<CardTitle>{t("settings.githubApi.title")}</CardTitle>
</div>
{!githubTokenLoading && (
<div className="flex items-center gap-2">
{githubTokenSaved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{githubTokenEditMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={() => {
setGithubTokenDraft("")
setGithubTokenError("")
setGithubTokenEditMode(false)
}}
disabled={githubTokenSaving}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={saveGithubToken}
disabled={githubTokenSaving || !githubTokenDraft.trim()}
>
{githubTokenSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => {
setGithubTokenError("")
setGithubTokenEditMode(true)
}}
>
<Settings2 className="h-3 w-3" />
{githubTokenConfigured ? t("actions.edit") : t("settings.githubApi.configure")}
</button>
)}
</div>
)}
</div>
<CardDescription>{t("settings.githubApi.description")}</CardDescription>
</CardHeader>
<CardContent className={githubTokenEditMode ? "bg-accent [&_input]:bg-background" : undefined}>
{githubTokenLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : githubTokenEditMode ? (
<div className="space-y-3">
<div className="space-y-1.5">
<label htmlFor="github-api-token" className="text-sm font-medium text-foreground">
{t("settings.githubApi.tokenLabel")}
</label>
<Input
id="github-api-token"
type="password"
autoComplete="new-password"
value={githubTokenDraft}
onChange={(event) => setGithubTokenDraft(event.target.value)}
placeholder={githubTokenConfigured ? "••••••••••••" : t("settings.githubApi.tokenPlaceholder")}
disabled={githubTokenSaving}
/>
<p className="text-xs text-muted-foreground">{t("settings.githubApi.tokenHelp")}</p>
</div>
{githubTokenConfigured && (
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={removeGithubToken}
disabled={githubTokenSaving}
>
<X className="h-4 w-4 mr-2" />
{t("settings.githubApi.removeToken")}
</Button>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm">
<span className={`h-2 w-2 rounded-full ${githubTokenConfigured ? "bg-green-500" : "bg-muted-foreground/60"}`} />
<span className={githubTokenConfigured ? "text-green-500" : "text-muted-foreground"}>
{githubTokenConfigured ? t("settings.githubApi.configured") : t("settings.githubApi.notConfigured")}
</span>
</div>
)}
{githubTokenError && (
<div className="mt-3 flex items-start gap-2 text-sm text-red-400">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{githubTokenError}</span>
</div>
)}
</CardContent>
</Card>
{/* Notification Settings */} {/* Notification Settings */}
<NotificationSettings /> <NotificationSettings />
+15
View File
@@ -1527,6 +1527,7 @@
"editButton": "Bearbeiten", "editButton": "Bearbeiten",
"upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams", "upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams",
"upstreamErrorNetwork": "Netzwerkfehler: {detail}", "upstreamErrorNetwork": "Netzwerkfehler: {detail}",
"upstreamErrorGithubRateLimit": "Das GitHub-Anfragelimit wurde erreicht. Konfigurieren Sie unter Einstellungen → GitHub API ein optionales Token oder versuchen Sie es später erneut.",
"upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}", "upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}",
"notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN zum Stummschalten klicken", "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN zum Stummschalten klicken",
"notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet zum Aktivieren klicken", "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet zum Aktivieren klicken",
@@ -1648,6 +1649,20 @@
"saveFailed": "Die Einstellung für die LXC-Update-Erkennung konnte nicht gespeichert werden.", "saveFailed": "Die Einstellung für die LXC-Update-Erkennung konnte nicht gespeichert werden.",
"purgedMessage": "{count} LXC-Einträge aus der Registrierung entfernt. Durch erneutes Aktivieren der Erkennung werden sie beim nächsten Scan-Zyklus neu aufgefüllt." "purgedMessage": "{count} LXC-Einträge aus der Registrierung entfernt. Durch erneutes Aktivieren der Erkennung werden sie beim nächsten Scan-Zyklus neu aufgefüllt."
}, },
"githubApi": {
"title": "GitHub API",
"description": "Optionale Authentifizierung für die Release- und Tag-Prüfungen registrierter Anwendungen.",
"configure": "Konfigurieren",
"tokenLabel": "Persönliches Zugriffstoken",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "Das Token wird verschlüsselt gespeichert. ProxMenux verwendet es ausschließlich für schreibgeschützte GitHub-API-Anfragen und zeigt es danach nicht erneut an.",
"configured": "Token konfiguriert",
"notConfigured": "Anonymes GitHub-Anfragekontingent wird verwendet",
"removeToken": "Token entfernen",
"loadFailed": "Die GitHub-API-Einstellung konnte nicht geladen werden.",
"saveFailed": "Das GitHub-API-Token konnte nicht gespeichert werden.",
"removeFailed": "Das GitHub-API-Token konnte nicht entfernt werden."
},
"notifications": { "notifications": {
"title": "Benachrichtigungen", "title": "Benachrichtigungen",
"description": "Konfigurieren Sie Benachrichtigungskanäle und Ereignisfilter. Erhalten Sie Benachrichtigungen per Telegram, Gotify, Discord oder E-Mail.", "description": "Konfigurieren Sie Benachrichtigungskanäle und Ereignisfilter. Erhalten Sie Benachrichtigungen per Telegram, Gotify, Discord oder E-Mail.",
+15
View File
@@ -1432,6 +1432,7 @@
"updateAvailableBadge": "Update available", "updateAvailableBadge": "Update available",
"upstreamErrorTimeout": "Network timeout while contacting upstream", "upstreamErrorTimeout": "Network timeout while contacting upstream",
"upstreamErrorNetwork": "Network error: {detail}", "upstreamErrorNetwork": "Network error: {detail}",
"upstreamErrorGithubRateLimit": "GitHub's request limit has been reached. Configure an optional token in Settings → GitHub API, or try again later.",
"upstreamErrorGeneric": "Upstream check failed: {detail}", "upstreamErrorGeneric": "Upstream check failed: {detail}",
"portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)", "portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)",
"portPortPlaceholder": "port", "portPortPlaceholder": "port",
@@ -1647,6 +1648,20 @@
"saveFailed": "Could not save the LXC update detection setting.", "saveFailed": "Could not save the LXC update detection setting.",
"purgedMessage": "{count} LXC entries removed from the registry. Re-enabling detection will repopulate them on the next scan cycle." "purgedMessage": "{count} LXC entries removed from the registry. Re-enabling detection will repopulate them on the next scan cycle."
}, },
"githubApi": {
"title": "GitHub API",
"description": "Optional authentication for the release and tag checks used by registered applications.",
"configure": "Configure",
"tokenLabel": "Personal access token",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "The token is stored encrypted. ProxMenux uses it only for read-only GitHub API requests and never displays it again.",
"configured": "Token configured",
"notConfigured": "Using GitHub's anonymous request quota",
"removeToken": "Remove token",
"loadFailed": "Could not load the GitHub API setting.",
"saveFailed": "Could not save the GitHub API token.",
"removeFailed": "Could not remove the GitHub API token."
},
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
"description": "Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.", "description": "Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.",
+15
View File
@@ -1432,6 +1432,7 @@
"upToDateBadge": "Actualizado", "upToDateBadge": "Actualizado",
"upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen", "upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen",
"upstreamErrorNetwork": "Error de red: {detail}", "upstreamErrorNetwork": "Error de red: {detail}",
"upstreamErrorGithubRateLimit": "Se ha alcanzado el límite de solicitudes de GitHub. Configure un token opcional en Ajustes → API de GitHub o vuelva a intentarlo más tarde.",
"upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}", "upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}",
"updateAvailableBadge": "Actualización disponible", "updateAvailableBadge": "Actualización disponible",
"portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)", "portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)",
@@ -1648,6 +1649,20 @@
"saveFailed": "No se pudo guardar la configuración de detección de actualizaciones de LXC.", "saveFailed": "No se pudo guardar la configuración de detección de actualizaciones de LXC.",
"purgedMessage": "{count} Entradas LXC eliminadas del registro. Al volver a habilitar la detección, se volverán a llenar en el siguiente ciclo de escaneo." "purgedMessage": "{count} Entradas LXC eliminadas del registro. Al volver a habilitar la detección, se volverán a llenar en el siguiente ciclo de escaneo."
}, },
"githubApi": {
"title": "API de GitHub",
"description": "Autenticación opcional para comprobar los lanzamientos y las etiquetas de las aplicaciones registradas.",
"configure": "Configurar",
"tokenLabel": "Token de acceso personal",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "El token se guarda cifrado. ProxMenux solo lo utiliza para consultas de solo lectura a la API de GitHub y no vuelve a mostrarlo.",
"configured": "Token configurado",
"notConfigured": "Usando la cuota de solicitudes anónimas de GitHub",
"removeToken": "Eliminar token",
"loadFailed": "No se pudo cargar el ajuste de la API de GitHub.",
"saveFailed": "No se pudo guardar el token de la API de GitHub.",
"removeFailed": "No se pudo eliminar el token de la API de GitHub."
},
"notifications": { "notifications": {
"title": "Notificaciones", "title": "Notificaciones",
"description": "Configure canales de notificación y filtros de eventos. Recibe alertas vía Telegram, Gotify, Discord o Email.", "description": "Configure canales de notificación y filtros de eventos. Recibe alertas vía Telegram, Gotify, Discord o Email.",
+15
View File
@@ -1527,6 +1527,7 @@
"editButton": "Modifier", "editButton": "Modifier",
"upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont", "upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont",
"upstreamErrorNetwork": "Erreur réseau : {detail}", "upstreamErrorNetwork": "Erreur réseau : {detail}",
"upstreamErrorGithubRateLimit": "La limite de requêtes GitHub a été atteinte. Configurez un jeton facultatif dans Paramètres → API GitHub ou réessayez plus tard.",
"upstreamErrorGeneric": "Échec de la vérification en amont : {detail}", "upstreamErrorGeneric": "Échec de la vérification en amont : {detail}",
"notificationsEnabled": "Notifications de mise à jour en amont activées  cliquez pour désactiver le son", "notificationsEnabled": "Notifications de mise à jour en amont activées  cliquez pour désactiver le son",
"notificationsMuted": "Notifications de mise à jour en amont MUTED  cliquez pour activer", "notificationsMuted": "Notifications de mise à jour en amont MUTED  cliquez pour activer",
@@ -1648,6 +1649,20 @@
"saveFailed": "Impossible d'enregistrer le paramètre de détection de mise à jour LXC.", "saveFailed": "Impossible d'enregistrer le paramètre de détection de mise à jour LXC.",
"purgedMessage": "{count} Entrées LXC supprimées du registre. La réactivation de la détection les repeuplera lors du prochain cycle d'analyse." "purgedMessage": "{count} Entrées LXC supprimées du registre. La réactivation de la détection les repeuplera lors du prochain cycle d'analyse."
}, },
"githubApi": {
"title": "API GitHub",
"description": "Authentification facultative pour vérifier les versions et les étiquettes des applications enregistrées.",
"configure": "Configurer",
"tokenLabel": "Jeton d'accès personnel",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "Le jeton est stocké sous forme chiffrée. ProxMenux l'utilise uniquement pour des requêtes en lecture seule vers l'API GitHub et ne l'affiche plus ensuite.",
"configured": "Jeton configuré",
"notConfigured": "Utilisation du quota de requêtes anonymes de GitHub",
"removeToken": "Supprimer le jeton",
"loadFailed": "Impossible de charger le paramètre de l'API GitHub.",
"saveFailed": "Impossible d'enregistrer le jeton de l'API GitHub.",
"removeFailed": "Impossible de supprimer le jeton de l'API GitHub."
},
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
"description": "Configurez les canaux de notification et les filtres d'événements. Recevez des alertes via Telegram, Gotify, Discord ou Email.", "description": "Configurez les canaux de notification et les filtres d'événements. Recevez des alertes via Telegram, Gotify, Discord ou Email.",
+15
View File
@@ -1527,6 +1527,7 @@
"editButton": "Modificare", "editButton": "Modificare",
"upstreamErrorTimeout": "timeout della rete durante il contatto a monte", "upstreamErrorTimeout": "timeout della rete durante il contatto a monte",
"upstreamErrorNetwork": "errore di rete: {detail}", "upstreamErrorNetwork": "errore di rete: {detail}",
"upstreamErrorGithubRateLimit": "È stato raggiunto il limite di richieste GitHub. Configura un token facoltativo in Impostazioni → API GitHub oppure riprova più tardi.",
"upstreamErrorGeneric": "controllo upstream non riuscito: {detail}", "upstreamErrorGeneric": "controllo upstream non riuscito: {detail}",
"notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio", "notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio",
"notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare", "notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare",
@@ -1648,6 +1649,20 @@
"saveFailed": "Impossibile salvare l'impostazione di rilevamento degli aggiornamenti LXC.", "saveFailed": "Impossibile salvare l'impostazione di rilevamento degli aggiornamenti LXC.",
"purgedMessage": "{count} Voci LXC rimosse dal registro. La riattivazione del rilevamento li ripopolarà al ciclo di scansione successivo." "purgedMessage": "{count} Voci LXC rimosse dal registro. La riattivazione del rilevamento li ripopolarà al ciclo di scansione successivo."
}, },
"githubApi": {
"title": "API GitHub",
"description": "Autenticazione facoltativa per controllare release e tag delle applicazioni registrate.",
"configure": "Configura",
"tokenLabel": "Token di accesso personale",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "Il token viene archiviato in forma cifrata. ProxMenux lo utilizza solo per richieste di sola lettura all'API GitHub e non lo mostra più dopo il salvataggio.",
"configured": "Token configurato",
"notConfigured": "Utilizzo della quota di richieste anonime di GitHub",
"removeToken": "Rimuovi token",
"loadFailed": "Impossibile caricare l'impostazione dell'API GitHub.",
"saveFailed": "Impossibile salvare il token dell'API GitHub.",
"removeFailed": "Impossibile rimuovere il token dell'API GitHub."
},
"notifications": { "notifications": {
"title": "Notifiche", "title": "Notifiche",
"description": "Configura canali di notifica e filtri eventi. Ricevi avvisi tramite Telegram, Gotify, Discord o e-mail.", "description": "Configura canali di notifica e filtri eventi. Ricevi avvisi tramite Telegram, Gotify, Discord o e-mail.",
+15
View File
@@ -1527,6 +1527,7 @@
"editButton": "Editar", "editButton": "Editar",
"upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream", "upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream",
"upstreamErrorNetwork": "Erro de rede: {detail}", "upstreamErrorNetwork": "Erro de rede: {detail}",
"upstreamErrorGithubRateLimit": "O limite de pedidos do GitHub foi atingido. Configure um token opcional em Definições → API do GitHub ou tente novamente mais tarde.",
"upstreamErrorGeneric": "falha na verificação upstream: {detail}", "upstreamErrorGeneric": "falha na verificação upstream: {detail}",
"notificationsEnabled": "Notificações de atualização upstream ATIVADAS clique para silenciar", "notificationsEnabled": "Notificações de atualização upstream ATIVADAS clique para silenciar",
"notificationsMuted": "notificações de atualização upstream silenciadas clique para ativar", "notificationsMuted": "notificações de atualização upstream silenciadas clique para ativar",
@@ -1648,6 +1649,20 @@
"saveFailed": "Não foi possível salvar a configuração de detecção de atualização LXC.", "saveFailed": "Não foi possível salvar a configuração de detecção de atualização LXC.",
"purgedMessage": "{count} Entradas LXC removidas do registro. Reativar a detecção irá preenchê-los novamente no próximo ciclo de verificação." "purgedMessage": "{count} Entradas LXC removidas do registro. Reativar a detecção irá preenchê-los novamente no próximo ciclo de verificação."
}, },
"githubApi": {
"title": "API do GitHub",
"description": "Autenticação opcional para verificar lançamentos e etiquetas das aplicações registadas.",
"configure": "Configurar",
"tokenLabel": "Token de acesso pessoal",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "O token é armazenado de forma cifrada. O ProxMenux utiliza-o apenas para pedidos de leitura à API do GitHub e não volta a apresentá-lo.",
"configured": "Token configurado",
"notConfigured": "A utilizar a quota de pedidos anónimos do GitHub",
"removeToken": "Remover token",
"loadFailed": "Não foi possível carregar a definição da API do GitHub.",
"saveFailed": "Não foi possível guardar o token da API do GitHub.",
"removeFailed": "Não foi possível remover o token da API do GitHub."
},
"notifications": { "notifications": {
"title": "Notificações", "title": "Notificações",
"description": "Configure canais de notificação e filtros de eventos. Receba alertas via Telegram, Gotify, Discord ou Email.", "description": "Configure canais de notificação e filtros de eventos. Receba alertas via Telegram, Gotify, Discord ou Email.",
+15
View File
@@ -1526,6 +1526,7 @@
"editButton": "Upraviť", "editButton": "Upraviť",
"upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream", "upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream",
"upstreamErrorNetwork": "Chyba siete: {detail}", "upstreamErrorNetwork": "Chyba siete: {detail}",
"upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.",
"upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}", "upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}",
"notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ kliknutím ich stlmíte", "notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ kliknutím ich stlmíte",
"notificationsMuted": "Upstream upozornenia na aktualizácie MUTED kliknutím aktivujete", "notificationsMuted": "Upstream upozornenia na aktualizácie MUTED kliknutím aktivujete",
@@ -1647,6 +1648,20 @@
"saveFailed": "Nastavenie kontroly aktualizácií LXC sa nepodarilo uložiť.", "saveFailed": "Nastavenie kontroly aktualizácií LXC sa nepodarilo uložiť.",
"purgedMessage": "Z registra bolo odstránených {count} LXC záznamov. Po opätovnom zapnutí kontroly sa doplnia pri ďalšom skenovaní." "purgedMessage": "Z registra bolo odstránených {count} LXC záznamov. Po opätovnom zapnutí kontroly sa doplnia pri ďalšom skenovaní."
}, },
"githubApi": {
"title": "GitHub API",
"description": "Voliteľné overenie pre kontrolu vydaní a značiek registrovaných aplikácií.",
"configure": "Nastaviť",
"tokenLabel": "Osobný prístupový token",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "Token sa ukladá šifrovane. ProxMenux ho používa iba na požiadavky GitHub API určené na čítanie a po uložení ho už nezobrazí.",
"configured": "Token je nastavený",
"notConfigured": "Používa sa anonymná kvóta požiadaviek GitHubu",
"removeToken": "Odstrániť token",
"loadFailed": "Nastavenie GitHub API sa nepodarilo načítať.",
"saveFailed": "Token GitHub API sa nepodarilo uložiť.",
"removeFailed": "Token GitHub API sa nepodarilo odstrániť."
},
"notifications": { "notifications": {
"title": "Notifikácie", "title": "Notifikácie",
"description": "Nastavte kanály a filtre udalostí. Upozornenia môžete dostávať cez Telegram, Gotify, Discord alebo e-mail.", "description": "Nastavte kanály a filtre udalostí. Upozornenia môžete dostávať cez Telegram, Gotify, Discord alebo e-mail.",
+15
View File
@@ -1527,6 +1527,7 @@
"editButton": "Redigera", "editButton": "Redigera",
"upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms", "upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms",
"upstreamErrorNetwork": "Nätverksfel: {detail}", "upstreamErrorNetwork": "Nätverksfel: {detail}",
"upstreamErrorGithubRateLimit": "GitHubs förfrågningsgräns har nåtts. Konfigurera en valfri token under Inställningar → GitHub API eller försök igen senare.",
"upstreamErrorGeneric": "Uppströmskontroll misslyckades: {detail}", "upstreamErrorGeneric": "Uppströmskontroll misslyckades: {detail}",
"notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ klicka för att stänga av ljudet", "notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ klicka för att stänga av ljudet",
"notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD klicka för att aktivera", "notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD klicka för att aktivera",
@@ -1648,6 +1649,20 @@
"saveFailed": "Det gick inte att spara inställningen för upptäckt av LXC-uppdatering.", "saveFailed": "Det gick inte att spara inställningen för upptäckt av LXC-uppdatering.",
"purgedMessage": "{count} LXC-poster har tagits bort från registret. Om detektering återaktiveras kommer de att fyllas på igen vid nästa skanningscykel." "purgedMessage": "{count} LXC-poster har tagits bort från registret. Om detektering återaktiveras kommer de att fyllas på igen vid nästa skanningscykel."
}, },
"githubApi": {
"title": "GitHub API",
"description": "Valfri autentisering för kontroller av utgåvor och taggar för registrerade appar.",
"configure": "Konfigurera",
"tokenLabel": "Personlig åtkomsttoken",
"tokenPlaceholder": "github_pat_...",
"tokenHelp": "Token lagras krypterad. ProxMenux använder den endast för skrivskyddade anrop till GitHub API och visar den inte igen efter att den har sparats.",
"configured": "Token konfigurerad",
"notConfigured": "GitHubs anonyma förfrågningskvot används",
"removeToken": "Ta bort token",
"loadFailed": "Det gick inte att läsa in GitHub API-inställningen.",
"saveFailed": "Det gick inte att spara GitHub API-token.",
"removeFailed": "Det gick inte att ta bort GitHub API-token."
},
"notifications": { "notifications": {
"title": "Aviseringar", "title": "Aviseringar",
"description": "Konfigurera aviseringskanaler och händelsefilter. Ta emot varningar via Telegram, Gotify, Discord eller e-post.", "description": "Konfigurera aviseringskanaler och händelsefilter. Ta emot varningar via Telegram, Gotify, Discord eller e-post.",
+48
View File
@@ -13747,6 +13747,54 @@ def api_apps_catalog():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/apps/github-token', methods=['GET'])
@require_auth
def api_apps_github_token_status():
"""Return whether the optional GitHub API token is configured.
The token itself is deliberately never returned to the client.
"""
try:
if not notification_manager._config:
notification_manager._load_config()
value = notification_manager._config.get('github_pat', '')
return jsonify({'configured': bool(isinstance(value, str) and value.strip())})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/github-token', methods=['PUT'])
@require_admin_scope
def api_apps_github_token_save():
"""Store the GitHub API token in the existing encrypted settings store."""
payload = request.get_json(silent=True) or {}
token = payload.get('token')
if not isinstance(token, str):
return jsonify({'error': 'token must be a string'}), 400
token = token.strip()
if not token:
return jsonify({'error': 'token is required'}), 400
if len(token) > 512:
return jsonify({'error': 'token exceeds the 512 character limit'}), 400
if any(ch.isspace() or ord(ch) < 33 or ord(ch) == 127 for ch in token):
return jsonify({'error': 'token contains whitespace or control characters'}), 400
result = notification_manager.save_settings({'github_pat': token})
if not result.get('success'):
return jsonify({'error': result.get('error', 'failed to save token')}), 500
return jsonify({'success': True, 'configured': True})
@app.route('/api/apps/github-token', methods=['DELETE'])
@require_admin_scope
def api_apps_github_token_remove():
"""Clear the optional GitHub API token without exposing its old value."""
result = notification_manager.save_settings({'github_pat': ''})
if not result.get('success'):
return jsonify({'error': result.get('error', 'failed to remove token')}), 500
return jsonify({'success': True, 'configured': False})
@app.route('/api/lxc-apps/dockerhub-tag-preview', methods=['POST']) @app.route('/api/lxc-apps/dockerhub-tag-preview', methods=['POST'])
@require_auth @require_auth
def api_lxc_apps_dockerhub_tag_preview(): def api_lxc_apps_dockerhub_tag_preview():
+8 -11
View File
@@ -1285,16 +1285,13 @@ def _select_working_hint_detector(vmid, hint: dict) -> tuple[dict, Optional[str]
def _github_pat() -> Optional[str]: def _github_pat() -> Optional[str]:
try: try:
from notification_manager import notification_manager from notification_manager import notification_manager
pat = notification_manager._config.get("github_pat") if notification_manager._config else None # The notification manager owns the shared encrypted settings store.
if not pat: # During very early calls its runtime cache may not have been loaded
return None # yet, so initialise it before reading the optional GitHub token.
try: if not notification_manager._config:
from notification_manager import decrypt_sensitive_value notification_manager._load_config()
if isinstance(pat, str) and pat.startswith("encrypted:"): pat = notification_manager._config.get("github_pat")
return decrypt_sensitive_value(pat) return pat.strip() if isinstance(pat, str) and pat.strip() else None
except Exception:
pass
return pat if isinstance(pat, str) else None
except Exception: except Exception:
return None return None
@@ -1365,7 +1362,7 @@ def _fetch_github_latest_details(config: dict) -> tuple[Optional[str], Optional[
if e.code == 403: if e.code == 403:
remaining = e.headers.get("X-RateLimit-Remaining", "1") remaining = e.headers.get("X-RateLimit-Remaining", "1")
if remaining == "0": if remaining == "0":
return None, "github rate limited — configure a PAT in Settings", None return None, "github rate limited — configure a PAT in Settings → GitHub API", None
return None, "github rejected the request (403)", None return None, "github rejected the request (403)", None
return None, f"github error {e.code}", None return None, f"github error {e.code}", None
except (urllib.error.URLError, TimeoutError, OSError) as e: except (urllib.error.URLError, TimeoutError, OSError) as e:
+78
View File
@@ -4147,6 +4147,17 @@ class ProxmoxHookWatcher:
'job_id': pve_job_id, 'job_id': pve_job_id,
} }
if pve_type == 'replication':
replication = self._extract_replication_context(
fields, title, message
)
data.update(replication)
entity_id = (
replication.get('job_id')
or replication.get('vmid')
or entity_id
)
# `system_problem` is the generic fallback of `_classify_pve` for # `system_problem` is the generic fallback of `_classify_pve` for
# unknown/empty pve_type. Without a populated `reason`, the template # unknown/empty pve_type. Without a populated `reason`, the template
# renders "Reason: " (empty) and `_summarize_event` falls back to # renders "Reason: " (empty) and `_summarize_event` falls back to
@@ -4274,6 +4285,73 @@ class ProxmoxHookWatcher:
self._queue.put(event) self._queue.put(event)
return {'accepted': True, 'event_type': event_type, 'event_id': event.event_id} return {'accepted': True, 'event_type': event_type, 'event_id': event.event_id}
def _extract_replication_context(self, fields: dict, title: str,
message: str) -> dict:
"""Map a native PVE replication notice to template fields."""
raw_job_id = fields.get('job-id') or fields.get('job_id') or ''
job_id = str(raw_job_id).strip()
if not re.fullmatch(r'\d+(?:-\d+)?', job_id):
combined = f'{title or ""}\n{message or ""}'
match = re.search(
r'\breplication(?:\s+job)?(?:\s*:\s*|\s+)'
r'[\'\"]?(\d+(?:-\d+)?)\b',
combined,
re.IGNORECASE,
)
if not match:
match = re.search(r'\b(\d+-\d+)\b', combined)
job_id = match.group(1) if match else ''
vmid_match = re.fullmatch(r'(\d+)(?:-\d+)?', job_id)
vmid = vmid_match.group(1) if vmid_match else ''
vmname = self._resolve_replication_guest_name(vmid)
if not vmname and vmid:
vmname = 'VM/CT'
target = str(
fields.get('job-target') or fields.get('target') or ''
).strip()
if not target:
target_match = re.search(
r'\bwith\s+target\s+[\'\"]([^\'\"\n]+)[\'\"]',
message or '',
re.IGNORECASE,
)
if target_match:
target = target_match.group(1).strip()
reason_match = re.search(
r'^\s*Error:\s*(.*?)\s*\Z',
message or '',
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
reason = reason_match.group(1).strip() if reason_match else ''
if not reason:
reason = (message or title or '').strip()
return {
'job_id': job_id,
'vmid': vmid,
'vmname': vmname,
'target_node': target,
'reason': reason,
}
@staticmethod
def _resolve_replication_guest_name(vmid: str) -> str:
"""Resolve the replicated guest name from the cluster config."""
if not vmid or not vmid.isdigit():
return ''
for base in ('/etc/pve/qemu-server', '/etc/pve/lxc'):
try:
with open(f'{base}/{vmid}.conf', encoding='utf-8') as config:
for line in config:
if line.startswith(('name:', 'hostname:')):
return line.split(':', 1)[1].strip()
except OSError:
continue
return ''
def _classify_pve(self, pve_type: str, severity: str, def _classify_pve(self, pve_type: str, severity: str,
title: str, message: str) -> tuple: title: str, message: str) -> tuple:
+4
View File
@@ -64,6 +64,10 @@ ENCRYPTION_KEY_FILE = Path('/usr/local/share/proxmenux/.notification_key')
# Keys that contain sensitive data and should be encrypted # Keys that contain sensitive data and should be encrypted
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
# Optional GitHub API token used by the LXC app version tracker.
# It lives in the shared settings store so it benefits from the same
# ENC2 encryption and never needs a second secrets file.
'github_pat',
'ai_api_key', # Legacy - kept for migration 'ai_api_key', # Legacy - kept for migration
'ai_api_key_groq', 'ai_api_key_groq',
'ai_api_key_gemini', 'ai_api_key_gemini',
@@ -0,0 +1,106 @@
import sys
import unittest
from pathlib import Path
from queue import Queue
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import notification_events # noqa: E402
import notification_templates # noqa: E402
class ReplicationWebhookTests(unittest.TestCase):
def _process(self, payload, guest_name='fileserver'):
watcher = notification_events.ProxmoxHookWatcher(Queue())
with mock.patch.object(
watcher,
'_resolve_replication_guest_name',
return_value=guest_name,
), mock.patch.object(
notification_events,
'capture_journal_context',
return_value='',
):
result = watcher.process_webhook(payload)
return result, watcher._queue.get_nowait()
def test_structured_job_id_populates_template_fields(self):
reason = 'command zfs error: cannot open pool\nremote side unavailable'
result, event = self._process({
'title': "Replication Job: '100-0' failed",
'message': (
"Replication job '100-0' with target 'pve02' and schedule "
"'*/15' failed!\n\n"
"Last successful sync: 2026-09-02 15:00:00\n"
"Next sync try: 2026-09-02 15:30:00\n"
"Failure count: 1\n\n"
f"Error:\n{reason}"
),
'severity': 'error',
'fields': {
'type': 'replication',
'hostname': 'pve01',
'job-id': '100-0',
},
})
self.assertTrue(result['accepted'])
self.assertEqual(event.event_type, 'replication_fail')
self.assertEqual(event.entity_id, '100-0')
self.assertEqual(event.data['job_id'], '100-0')
self.assertEqual(event.data['vmid'], '100')
self.assertEqual(event.data['vmname'], 'fileserver')
self.assertEqual(event.data['target_node'], 'pve02')
self.assertEqual(event.data['reason'], reason)
rendered = notification_templates.render_template(
event.event_type,
event.data,
)
self.assertIn('fileserver (100)', rendered['title'])
self.assertIn('ID: 100', rendered['body_text'])
self.assertIn(reason, rendered['body_text'])
def test_title_and_message_are_used_when_job_id_field_is_missing(self):
_, event = self._process({
'title': "Replication Job: '212-3' failed",
'message': (
"Replication job '212-3' with target 'pve03' failed!\n\n"
"Error: storage 'replica-zfs' is not available"
),
'severity': 'error',
'fields': {'type': 'replication', 'hostname': 'pve01'},
}, guest_name='')
self.assertEqual(event.entity_id, '212-3')
self.assertEqual(event.data['vmid'], '212')
self.assertEqual(event.data['vmname'], 'VM/CT')
self.assertEqual(event.data['target_node'], 'pve03')
self.assertEqual(
event.data['reason'],
"storage 'replica-zfs' is not available",
)
def test_missing_error_block_never_renders_an_empty_reason(self):
message = "Replication job '300-0' failed unexpectedly"
_, event = self._process({
'title': "Replication Job: '300-0' failed",
'message': message,
'severity': 'error',
'fields': {'type': 'replication', 'hostname': 'pve01'},
})
self.assertEqual(event.data['reason'], message)
rendered = notification_templates.render_template(
event.event_type,
event.data,
)
self.assertIn(f'Reason: {message}', rendered['body_text'])
if __name__ == '__main__':
unittest.main()