mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
refine post-install and hardware GPU docs, Monitor UX and CLI styling
- rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow) - migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale - fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `<iface>` placeholder in automated so intl no longer parses it as a tag - remove the mouse-follow blue overlay from the docs landing layout - reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile - move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile - show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent - restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate - add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel
This commit is contained in:
@@ -2402,12 +2402,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
detections, so the user gets one-click Restore before hand-
|
||||
typing a custom app. */}
|
||||
{apps.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end items-center gap-2">
|
||||
<div className="flex flex-col items-stretch gap-2 max-w-xs mx-auto sm:flex-row sm:flex-wrap sm:justify-end sm:items-center sm:max-w-none sm:mx-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={searchInstalledApplications}
|
||||
disabled={searchingApplications || editMode}
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
{searchingApplications
|
||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
@@ -2421,6 +2422,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
size="sm"
|
||||
onClick={openBrowseOrEditor}
|
||||
disabled={editMode}
|
||||
className="w-full sm:w-auto order-3 sm:order-2"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("vmLxc.appEditor.addAnotherApplication")}
|
||||
@@ -2433,7 +2435,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
|
||||
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center justify-center gap-1.5 w-full sm:w-auto order-1 sm:order-3"
|
||||
>
|
||||
{editMode ? (
|
||||
<>
|
||||
|
||||
@@ -30,6 +30,12 @@ interface ChannelConfig {
|
||||
url?: string
|
||||
token?: string
|
||||
webhook_url?: string
|
||||
// Pushover channel fields
|
||||
user_key?: string
|
||||
api_token?: string
|
||||
device?: string
|
||||
sound?: string
|
||||
critical_priority?: boolean
|
||||
// Email channel fields
|
||||
host?: string
|
||||
port?: string
|
||||
@@ -146,7 +152,7 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
||||
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "other"].map(key => ({ key }))
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "apprise"] as const
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||
|
||||
const AI_PROVIDERS = [
|
||||
{
|
||||
@@ -242,6 +248,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
email: { enabled: false },
|
||||
pushover: { enabled: false, critical_priority: true },
|
||||
apprise: { enabled: false },
|
||||
},
|
||||
event_categories: {
|
||||
@@ -256,6 +263,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: { categories: {}, events: {} },
|
||||
discord: { categories: {}, events: {} },
|
||||
email: { categories: {}, events: {} },
|
||||
pushover: { categories: {}, events: {} },
|
||||
apprise: { categories: {}, events: {} },
|
||||
},
|
||||
ai_enabled: false,
|
||||
@@ -287,6 +295,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: "brief",
|
||||
discord: "brief",
|
||||
email: "detailed",
|
||||
pushover: "brief",
|
||||
apprise: "brief",
|
||||
},
|
||||
hostname: "",
|
||||
@@ -1070,6 +1079,8 @@ export function NotificationSettings() {
|
||||
gt_token: { channel: "gotify", field: "token" },
|
||||
dc_hook: { channel: "discord", field: "webhook_url" },
|
||||
em_pass: { channel: "email", field: "password" },
|
||||
po_user: { channel: "pushover", field: "user_key" },
|
||||
po_token: { channel: "pushover", field: "api_token" },
|
||||
apprise_url: { channel: "apprise", field: "url" },
|
||||
}
|
||||
|
||||
@@ -1429,7 +1440,7 @@ export function NotificationSettings() {
|
||||
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<Tabs defaultValue="telegram" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-5 h-8">
|
||||
<TabsList className="w-full grid grid-cols-3 sm:grid-cols-6 h-auto">
|
||||
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
|
||||
Telegram
|
||||
</TabsTrigger>
|
||||
@@ -1442,6 +1453,9 @@ export function NotificationSettings() {
|
||||
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pushover" className="text-xs data-[state=active]:text-rose-500">
|
||||
Pushover
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apprise" className="text-xs data-[state=active]:text-cyan-500">
|
||||
Apprise
|
||||
</TabsTrigger>
|
||||
@@ -1876,10 +1890,145 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Pushover */}
|
||||
<TabsContent value="pushover" className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enablePushover")}</Label>
|
||||
<a
|
||||
href="https://pushover.net/apps/build"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-rose-500 hover:text-rose-400 hover:underline"
|
||||
>
|
||||
{t("settings.notifications.ui.setupGuide")}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.channels.pushover?.enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => { if (editMode) updateChannel("pushover", "enabled", !config.channels.pushover?.enabled) }}
|
||||
disabled={!editMode}
|
||||
role="switch"
|
||||
aria-checked={config.channels.pushover?.enabled || false}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.pushover?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{config.channels.pushover?.enabled && (
|
||||
<>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("settings.notifications.ui.pushoverCredentialsHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverUserKey")}</Label>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Input
|
||||
type={showSecrets["po_user"] ? "text" : "password"}
|
||||
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
|
||||
value={secretValue("po_user", config.channels.pushover?.user_key || "")}
|
||||
onChange={e => updateChannel("pushover", "user_key", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
||||
onClick={() => toggleSecret("po_user")}
|
||||
>
|
||||
{showSecrets["po_user"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverApiToken")}</Label>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Input
|
||||
type={showSecrets["po_token"] ? "text" : "password"}
|
||||
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi"
|
||||
value={secretValue("po_token", config.channels.pushover?.api_token || "")}
|
||||
onChange={e => updateChannel("pushover", "api_token", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
||||
onClick={() => toggleSecret("po_token")}
|
||||
>
|
||||
{showSecrets["po_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverDevice")} ({t("settings.notifications.ui.optional")})</Label>
|
||||
<Input
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="iphone"
|
||||
value={config.channels.pushover?.device || ""}
|
||||
onChange={e => updateChannel("pushover", "device", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverSound")} ({t("settings.notifications.ui.optional")})</Label>
|
||||
<Input
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="pushover"
|
||||
value={config.channels.pushover?.sound || ""}
|
||||
onChange={e => updateChannel("pushover", "sound", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-1">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.pushoverCriticalPriority")}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.pushoverCriticalPriorityDescription")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={config.channels.pushover?.critical_priority !== false}
|
||||
disabled={!editMode}
|
||||
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
|
||||
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
} ${config.channels.pushover?.critical_priority !== false ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
|
||||
onClick={() => { if (editMode) updateChannel("pushover", "critical_priority", config.channels.pushover?.critical_priority === false) }}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.pushover?.critical_priority !== false ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{renderChannelCategories("pushover")}
|
||||
{renderQuietHours("pushover")}
|
||||
{renderDailyDigest("pushover")}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-rose-600 hover:bg-rose-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
|
||||
onClick={() => handleTest("pushover")}
|
||||
disabled={testing === "pushover" || !config.channels.pushover?.user_key || !config.channels.pushover?.api_token}
|
||||
>
|
||||
{testing === "pushover" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||
{t("settings.notifications.ui.sendTest")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Apprise — issue #207. Single URL talks to ~80
|
||||
notification services. The operator pastes one
|
||||
`tgram://`, `discord://`, `ntfy://`, `matrix://`,
|
||||
`pushover://` etc. URL and the AppriseChannel
|
||||
`pover://` etc. URL and the AppriseChannel
|
||||
backend handles the transport. Mirrors the same
|
||||
Enable toggle + Test button pattern as the other
|
||||
channels. */}
|
||||
@@ -1888,7 +2037,7 @@ export function NotificationSettings() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||
<a
|
||||
href="https://github.com/caronc/apprise/wiki"
|
||||
href="https://appriseit.com/services/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-500 hover:text-cyan-400 hover:underline"
|
||||
@@ -1946,11 +2095,11 @@ export function NotificationSettings() {
|
||||
<code className="text-foreground/80 mx-0.5">slack://</code>,
|
||||
<code className="text-foreground/80 mx-0.5">ntfy://</code>,
|
||||
<code className="text-foreground/80 mx-0.5">matrix://</code>,
|
||||
<code className="text-foreground/80 mx-0.5">pushover://</code>,
|
||||
<code className="text-foreground/80 mx-0.5">pover://</code>,
|
||||
<code className="text-foreground/80 mx-0.5">mailto://</code>… {t("settings.notifications.ui.seeThe")}
|
||||
{" "}
|
||||
<a
|
||||
href="https://github.com/caronc/apprise/wiki"
|
||||
href="https://appriseit.com/services/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-500 hover:underline"
|
||||
|
||||
@@ -5764,14 +5764,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{t("vmLxc.bulkUpdate.description")}
|
||||
</p>
|
||||
</div>
|
||||
{!bulkEditMode && bulkConfigured && (
|
||||
{!bulkEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkEditMode(true)}
|
||||
onClick={() => {
|
||||
if (!bulkConfigured) {
|
||||
setBulkTargets(["os"])
|
||||
setBulkError(null)
|
||||
}
|
||||
setBulkEditMode(true)
|
||||
}}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.bulkUpdate.edit")}
|
||||
{bulkConfigured
|
||||
? t("vmLxc.bulkUpdate.edit")
|
||||
: t("vmLxc.bulkUpdate.configure")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -5932,22 +5940,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vmLxc.bulkUpdate.notConfigured")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBulkTargets(["os"])
|
||||
setBulkEditMode(true)
|
||||
setBulkError(null)
|
||||
}}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center justify-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.bulkUpdate.configure")}
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vmLxc.bulkUpdate.notConfigured")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1839,6 +1839,14 @@
|
||||
"toAddresses": "An Adressen (durch Kommas getrennt)",
|
||||
"subjectPrefix": "Betreff-Präfix",
|
||||
"emailHint": "Lassen Sie den SMTP-Host leer, um lokales Sendmail zu verwenden. Verwenden Sie für Gmail ein App-Passwort.",
|
||||
"enablePushover": "Pushover aktivieren",
|
||||
"pushoverUserKey": "Benutzer- oder Gruppenschlüssel",
|
||||
"pushoverApiToken": "API-Token der Anwendung",
|
||||
"pushoverDevice": "Gerät",
|
||||
"pushoverSound": "Ton",
|
||||
"pushoverCriticalPriority": "Hohe Priorität für kritische Warnungen",
|
||||
"pushoverCriticalPriorityDescription": "KRITISCHE Benachrichtigungen verwenden die hohe Pushover-Priorität. Die Notfallpriorität wird nicht verwendet.",
|
||||
"pushoverCredentialsHint": "Erstellen Sie eine Anwendung in Pushover und kopieren Sie deren API-Token sowie Ihren Benutzer- oder Gruppenschlüssel.",
|
||||
"enableApprise": "Aktivieren Sie Apprise",
|
||||
"urlFormats": "+URL-Formate",
|
||||
"appriseUrl": "Apprise-URL",
|
||||
|
||||
@@ -1838,6 +1838,14 @@
|
||||
"toAddresses": "To addresses (comma-separated)",
|
||||
"subjectPrefix": "Subject prefix",
|
||||
"emailHint": "Leave SMTP host empty to use local sendmail. For Gmail, use an app password.",
|
||||
"enablePushover": "Enable Pushover",
|
||||
"pushoverUserKey": "User or group key",
|
||||
"pushoverApiToken": "Application API token",
|
||||
"pushoverDevice": "Device",
|
||||
"pushoverSound": "Sound",
|
||||
"pushoverCriticalPriority": "High priority for critical alerts",
|
||||
"pushoverCriticalPriorityDescription": "CRITICAL notifications use Pushover high priority. Emergency priority is not used.",
|
||||
"pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.",
|
||||
"enableApprise": "Enable Apprise",
|
||||
"urlFormats": "+URL formats",
|
||||
"appriseUrl": "Apprise URL",
|
||||
|
||||
@@ -1825,7 +1825,7 @@
|
||||
"enableGotify": "Habilitar Gotify",
|
||||
"serverUrl": "URL del servidor",
|
||||
"appToken": "Ficha de aplicación",
|
||||
"enableDiscord": "Habilitar discordia",
|
||||
"enableDiscord": "Activar Discord",
|
||||
"webhookUrl": "URL de webhook",
|
||||
"enableEmail": "Habilitar correo electrónico",
|
||||
"smtpHost": "servidor SMTP",
|
||||
@@ -1839,9 +1839,17 @@
|
||||
"toAddresses": "A direcciones (separadas por comas)",
|
||||
"subjectPrefix": "Prefijo de asunto",
|
||||
"emailHint": "Deje el host SMTP vacío para usar sendmail local. Para Gmail, utilice una contraseña de aplicación.",
|
||||
"enableApprise": "Habilitar información",
|
||||
"enablePushover": "Activar Pushover",
|
||||
"pushoverUserKey": "Clave de usuario o grupo",
|
||||
"pushoverApiToken": "Token API de la aplicación",
|
||||
"pushoverDevice": "Dispositivo",
|
||||
"pushoverSound": "Sonido",
|
||||
"pushoverCriticalPriority": "Prioridad alta para alertas críticas",
|
||||
"pushoverCriticalPriorityDescription": "Las notificaciones CRÍTICAS usan la prioridad alta de Pushover. No se utiliza la prioridad de emergencia.",
|
||||
"pushoverCredentialsHint": "Cree una aplicación en Pushover y copie su token API y la clave de usuario o grupo.",
|
||||
"enableApprise": "Activar Apprise",
|
||||
"urlFormats": "+formatos de URL",
|
||||
"appriseUrl": "Informar URL",
|
||||
"appriseUrl": "URL de Apprise",
|
||||
"showUrl": "Mostrar URL",
|
||||
"hideUrl": "Ocultar URL",
|
||||
"appriseDescription": "Una URL permite a Apprise enrutar la notificación al servicio correcto. Ejemplos:",
|
||||
|
||||
@@ -1839,9 +1839,17 @@
|
||||
"toAddresses": "Aux adresses (séparées par des virgules)",
|
||||
"subjectPrefix": "Préfixe du sujet",
|
||||
"emailHint": "Laissez l'hôte SMTP vide pour utiliser sendmail local. Pour Gmail, utilisez un mot de passe d'application.",
|
||||
"enablePushover": "Activer Pushover",
|
||||
"pushoverUserKey": "Clé utilisateur ou groupe",
|
||||
"pushoverApiToken": "Jeton API de l’application",
|
||||
"pushoverDevice": "Appareil",
|
||||
"pushoverSound": "Son",
|
||||
"pushoverCriticalPriority": "Priorité élevée pour les alertes critiques",
|
||||
"pushoverCriticalPriorityDescription": "Les notifications CRITIQUES utilisent la priorité élevée de Pushover. La priorité d’urgence n’est pas utilisée.",
|
||||
"pushoverCredentialsHint": "Créez une application dans Pushover, puis copiez son jeton API et votre clé utilisateur ou groupe.",
|
||||
"enableApprise": "Activer Apprise",
|
||||
"urlFormats": "+Formats d'URL",
|
||||
"appriseUrl": "URL d'information",
|
||||
"appriseUrl": "URL Apprise",
|
||||
"showUrl": "Afficher l'URL",
|
||||
"hideUrl": "Masquer l'URL",
|
||||
"appriseDescription": "Une URL permet à Apprise d'acheminer la notification vers le bon service. Exemples :",
|
||||
|
||||
@@ -1839,9 +1839,17 @@
|
||||
"toAddresses": "Agli indirizzi (separati da virgole)",
|
||||
"subjectPrefix": "Prefisso oggetto",
|
||||
"emailHint": "Lascia vuoto l'host SMTP per utilizzare sendmail locale. Per Gmail, utilizza una password per l'app.",
|
||||
"enableApprise": "Abilita Appres",
|
||||
"enablePushover": "Abilita Pushover",
|
||||
"pushoverUserKey": "Chiave utente o gruppo",
|
||||
"pushoverApiToken": "Token API dell’applicazione",
|
||||
"pushoverDevice": "Dispositivo",
|
||||
"pushoverSound": "Suono",
|
||||
"pushoverCriticalPriority": "Priorità alta per gli avvisi critici",
|
||||
"pushoverCriticalPriorityDescription": "Le notifiche CRITICHE usano la priorità alta di Pushover. La priorità di emergenza non viene utilizzata.",
|
||||
"pushoverCredentialsHint": "Crea un’applicazione in Pushover e copia il relativo token API e la chiave utente o gruppo.",
|
||||
"enableApprise": "Abilita Apprise",
|
||||
"urlFormats": "+Formati URL",
|
||||
"appriseUrl": "Informare l'URL",
|
||||
"appriseUrl": "URL di Apprise",
|
||||
"showUrl": "Mostra URL",
|
||||
"hideUrl": "Nascondi l'URL",
|
||||
"appriseDescription": "Un URL consente ad Apprise di indirizzare la notifica al servizio giusto. Esempi:",
|
||||
|
||||
@@ -1839,9 +1839,17 @@
|
||||
"toAddresses": "Para endereços (separados por vírgula)",
|
||||
"subjectPrefix": "Prefixo do assunto",
|
||||
"emailHint": "Deixe o host SMTP vazio para usar o sendmail local. Para Gmail, use uma senha de aplicativo.",
|
||||
"enableApprise": "Habilitar informar",
|
||||
"enablePushover": "Ativar Pushover",
|
||||
"pushoverUserKey": "Chave de utilizador ou grupo",
|
||||
"pushoverApiToken": "Token API da aplicação",
|
||||
"pushoverDevice": "Dispositivo",
|
||||
"pushoverSound": "Som",
|
||||
"pushoverCriticalPriority": "Prioridade alta para alertas críticos",
|
||||
"pushoverCriticalPriorityDescription": "As notificações CRÍTICAS usam a prioridade alta do Pushover. A prioridade de emergência não é utilizada.",
|
||||
"pushoverCredentialsHint": "Crie uma aplicação no Pushover e copie o respetivo token API e a chave de utilizador ou grupo.",
|
||||
"enableApprise": "Ativar Apprise",
|
||||
"urlFormats": "+Formatos de URL",
|
||||
"appriseUrl": "Informar URL",
|
||||
"appriseUrl": "URL do Apprise",
|
||||
"showUrl": "Mostrar URL",
|
||||
"hideUrl": "Ocultar URL",
|
||||
"appriseDescription": "Um URL permite que o Apprise encaminhe a notificação para o serviço certo. Exemplos:",
|
||||
|
||||
@@ -1838,6 +1838,14 @@
|
||||
"toAddresses": "Adresy príjemcov (oddelené čiarkou)",
|
||||
"subjectPrefix": "Predpona predmetu",
|
||||
"emailHint": "Ak SMTP server necháte prázdny, použije sa lokálny sendmail. Pre Gmail použite heslo aplikácie.",
|
||||
"enablePushover": "Zapnúť Pushover",
|
||||
"pushoverUserKey": "Kľúč používateľa alebo skupiny",
|
||||
"pushoverApiToken": "API token aplikácie",
|
||||
"pushoverDevice": "Zariadenie",
|
||||
"pushoverSound": "Zvuk",
|
||||
"pushoverCriticalPriority": "Vysoká priorita pre kritické upozornenia",
|
||||
"pushoverCriticalPriorityDescription": "KRITICKÉ notifikácie používajú vysokú prioritu Pushover. Núdzová priorita sa nepoužíva.",
|
||||
"pushoverCredentialsHint": "Vytvorte aplikáciu v službe Pushover a skopírujte jej API token a kľúč používateľa alebo skupiny.",
|
||||
"enableApprise": "Zapnúť Apprise",
|
||||
"urlFormats": "+formáty URL",
|
||||
"appriseUrl": "URL Apprise",
|
||||
|
||||
@@ -1839,9 +1839,17 @@
|
||||
"toAddresses": "Till adresser (kommaseparerade)",
|
||||
"subjectPrefix": "Ämnesprefix",
|
||||
"emailHint": "Lämna SMTP-värden tom för att använda lokal sendmail. Använd ett applösenord för Gmail.",
|
||||
"enablePushover": "Aktivera Pushover",
|
||||
"pushoverUserKey": "Användar- eller gruppnyckel",
|
||||
"pushoverApiToken": "Appens API-token",
|
||||
"pushoverDevice": "Enhet",
|
||||
"pushoverSound": "Ljud",
|
||||
"pushoverCriticalPriority": "Hög prioritet för kritiska varningar",
|
||||
"pushoverCriticalPriorityDescription": "KRITISKA aviseringar använder hög prioritet i Pushover. Nödprioritet används inte.",
|
||||
"pushoverCredentialsHint": "Skapa en app i Pushover och kopiera dess API-token och din användar- eller gruppnyckel.",
|
||||
"enableApprise": "Aktivera Apprise",
|
||||
"urlFormats": "+URL-format",
|
||||
"appriseUrl": "Upplys URL",
|
||||
"appriseUrl": "Apprise-URL",
|
||||
"showUrl": "Visa URL",
|
||||
"hideUrl": "Dölj URL",
|
||||
"appriseDescription": "En URL låter Apprise dirigera meddelandet till rätt tjänst. Exempel:",
|
||||
|
||||
@@ -1055,10 +1055,13 @@ 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_REFRESH_LOCK = threading.Lock()
|
||||
_SSL_RUNTIME_CONTEXT = None
|
||||
_SSL_RUNTIME_FINGERPRINT = ""
|
||||
_SSL_RUNTIME_CERT_PATH = ""
|
||||
_SSL_RUNTIME_KEY_PATH = ""
|
||||
_SSL_RUNTIME_SOURCE = "none"
|
||||
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||
|
||||
|
||||
def load_ssl_config():
|
||||
@@ -1100,6 +1103,15 @@ def save_ssl_config(config):
|
||||
return False
|
||||
|
||||
|
||||
def _detect_proxmox_certificate_paths():
|
||||
"""Return the certificate pair currently preferred by Proxmox."""
|
||||
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
|
||||
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
||||
return "", ""
|
||||
|
||||
|
||||
def detect_proxmox_certificates():
|
||||
"""
|
||||
Detect available Proxmox certificates.
|
||||
@@ -1117,11 +1129,10 @@ def detect_proxmox_certificates():
|
||||
"cert_info": None
|
||||
}
|
||||
|
||||
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
|
||||
result["proxmox_cert"] = PROXMOX_CUSTOM_CERT_PATH
|
||||
result["proxmox_key"] = PROXMOX_CUSTOM_KEY_PATH
|
||||
result["proxmox_available"] = True
|
||||
elif os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||
cert_path, key_path = _detect_proxmox_certificate_paths()
|
||||
if cert_path and key_path:
|
||||
result["proxmox_cert"] = cert_path
|
||||
result["proxmox_key"] = key_path
|
||||
result["proxmox_available"] = True
|
||||
|
||||
if result["proxmox_available"]:
|
||||
@@ -1209,17 +1220,112 @@ def _build_server_ssl_context(cert_path, key_path):
|
||||
return context
|
||||
|
||||
|
||||
def _record_ssl_refresh_error(error):
|
||||
"""Log one warning per distinct automatic-refresh failure."""
|
||||
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||
|
||||
message = str(error)
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
if message == _SSL_RUNTIME_LAST_REFRESH_ERROR:
|
||||
return
|
||||
_SSL_RUNTIME_LAST_REFRESH_ERROR = message
|
||||
print(
|
||||
"[ProxMenux] Proxmox TLS certificate refresh skipped; "
|
||||
f"the active certificate remains unchanged: {message}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _persist_active_proxmox_certificate_paths(cert_path, key_path):
|
||||
"""Keep the selected Proxmox pair in sync for the next service start."""
|
||||
config = load_ssl_config()
|
||||
if not config.get("enabled") or config.get("source") != "proxmox":
|
||||
return
|
||||
if config.get("cert_path") == cert_path and config.get("key_path") == key_path:
|
||||
return
|
||||
|
||||
updated_config = dict(config)
|
||||
updated_config["cert_path"] = cert_path
|
||||
updated_config["key_path"] = key_path
|
||||
if not save_ssl_config(updated_config):
|
||||
print(
|
||||
"[ProxMenux] Warning: the renewed Proxmox certificate is active, "
|
||||
"but its paths could not be saved for the next service start",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_proxmox_ssl_context_for_handshake():
|
||||
"""Activate a renewed Proxmox pair just before a TLS handshake.
|
||||
|
||||
This deliberately has no timer and does not depend on inotify (pmxcfs can
|
||||
update /etc/pve without emitting a local event). The small PEM pair is
|
||||
inspected only when a client starts a new TLS connection. Any missing,
|
||||
partial or mismatched pair leaves the already-active context untouched.
|
||||
"""
|
||||
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
|
||||
return False
|
||||
|
||||
# Several browser connections can arrive together. Only one of them may
|
||||
# validate/swap a newly written pair; the others reuse its result.
|
||||
with _SSL_RUNTIME_REFRESH_LOCK:
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
|
||||
return False
|
||||
active_fingerprint = _SSL_RUNTIME_FINGERPRINT
|
||||
active_cert_path = _SSL_RUNTIME_CERT_PATH
|
||||
active_key_path = _SSL_RUNTIME_KEY_PATH
|
||||
|
||||
cert_path, key_path = _detect_proxmox_certificate_paths()
|
||||
if not cert_path or not key_path:
|
||||
raise RuntimeError("No complete Proxmox certificate/key pair was detected")
|
||||
|
||||
candidate_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
paths_changed = cert_path != active_cert_path or key_path != active_key_path
|
||||
if candidate_fingerprint == active_fingerprint and not paths_changed:
|
||||
return False
|
||||
|
||||
# reload_server_ssl_context builds and validates the replacement first
|
||||
# and checks that neither PEM changed while it was being loaded. The
|
||||
# global context is swapped only after all of those checks succeed.
|
||||
changed = reload_server_ssl_context(cert_path, key_path)
|
||||
_persist_active_proxmox_certificate_paths(cert_path, key_path)
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||
|
||||
if changed:
|
||||
print(
|
||||
f"[ProxMenux] Renewed Proxmox TLS certificate activated from {cert_path}",
|
||||
flush=True,
|
||||
)
|
||||
return changed
|
||||
|
||||
|
||||
def create_reloadable_ssl_context(cert_path, key_path):
|
||||
"""Create the server context and register it for manual hot reloads."""
|
||||
"""Create the stable server context used by automatic and manual reloads."""
|
||||
global _SSL_RUNTIME_CONTEXT
|
||||
global _SSL_RUNTIME_FINGERPRINT
|
||||
global _SSL_RUNTIME_CERT_PATH
|
||||
global _SSL_RUNTIME_KEY_PATH
|
||||
global _SSL_RUNTIME_SOURCE
|
||||
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||
|
||||
context = _build_server_ssl_context(cert_path, key_path)
|
||||
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
config = load_ssl_config()
|
||||
source = config.get("source", "none") if config.get("enabled") else "none"
|
||||
|
||||
def _select_active_context(ssl_socket, _server_name, _initial_context):
|
||||
try:
|
||||
_refresh_proxmox_ssl_context_for_handshake()
|
||||
except Exception as error:
|
||||
# Never fail a client handshake because Proxmox is between the
|
||||
# certificate and key writes. The previously validated context
|
||||
# remains authoritative until a later connection can load both.
|
||||
_record_ssl_refresh_error(error)
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
active_context = _SSL_RUNTIME_CONTEXT
|
||||
if active_context is not None and ssl_socket.context is not active_context:
|
||||
@@ -1231,6 +1337,8 @@ def create_reloadable_ssl_context(cert_path, key_path):
|
||||
_SSL_RUNTIME_FINGERPRINT = fingerprint
|
||||
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||
_SSL_RUNTIME_KEY_PATH = key_path
|
||||
_SSL_RUNTIME_SOURCE = source
|
||||
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||
return context
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ TOOL_METADATA = {
|
||||
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
|
||||
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
|
||||
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
|
||||
'rpc': {'name': 'RPC / rpcbind Disable', 'function': 'disable_rpc', 'version': '1.0'},
|
||||
'motd': {'name': 'Custom MOTD Banner', 'function': 'setup_motd', 'version': '1.0'},
|
||||
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
|
||||
@@ -1123,8 +1123,8 @@ def _check_oci_app(entry: dict) -> dict:
|
||||
# returns the single newest version, e.g. "580.105.08"
|
||||
# `https://download.nvidia.com/XFree86/Linux-x86_64/`
|
||||
# HTML directory listing — we scrape it for per-branch latest
|
||||
# (so a user on 570.x gets 570.x's latest, not pushed to 580.x
|
||||
# unless their kernel forces a branch upgrade).
|
||||
# (so a user on 570.x gets 570.x's latest, without an automatic
|
||||
# cross-branch upgrade).
|
||||
#
|
||||
# Cache TTL is 7 days because NVIDIA's release cadence on each branch
|
||||
# is roughly monthly. The cache is in-memory only; AppImage restarts
|
||||
@@ -1135,15 +1135,6 @@ _NVIDIA_CACHE_TTL = 7 * 86400
|
||||
_nvidia_cache: dict[str, Any] = {"versions": [], "fetched_at": 0}
|
||||
|
||||
|
||||
def _kernel_string() -> str:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["uname", "-r"], capture_output=True, text=True, timeout=2,
|
||||
).stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple:
|
||||
"""Convert ``580.105.08`` → ``(580, 105, 8)`` for comparison.
|
||||
Pads to 3 components so ``580.82`` < ``580.105.08``."""
|
||||
@@ -1216,7 +1207,6 @@ def _check_nvidia_xfree86(entry: dict) -> dict:
|
||||
"last_check": _now_iso(),
|
||||
"error": None,
|
||||
"_upgrade_kind": "patch" if available else None,
|
||||
"_kernel": _kernel_string(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
ProxMenux Notification Channels
|
||||
Provides transport adapters for Telegram, Gotify, and Discord.
|
||||
Provides transport adapters for Telegram, Gotify, Discord, Email, Pushover,
|
||||
and Apprise.
|
||||
|
||||
Each channel implements send() and test() with:
|
||||
- Retry with exponential backoff (3 attempts)
|
||||
@@ -12,6 +13,7 @@ Author: MacRimi
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
@@ -392,6 +394,119 @@ class GotifyChannel(NotificationChannel):
|
||||
return self._http_request(url, payload, {'Content-Type': 'application/json'})
|
||||
|
||||
|
||||
# ─── Pushover ────────────────────────────────────────────────────
|
||||
|
||||
class PushoverChannel(NotificationChannel):
|
||||
"""Pushover Messages API channel."""
|
||||
|
||||
API_URL = 'https://api.pushover.net/1/messages.json'
|
||||
MAX_TITLE_LENGTH = 250
|
||||
MAX_MESSAGE_LENGTH = 1024
|
||||
_CREDENTIAL_RE = re.compile(r'^[A-Za-z0-9]{30}$')
|
||||
_OPTION_RE = re.compile(r'^[A-Za-z0-9_-]{1,25}$')
|
||||
|
||||
def __init__(self, user_key: str, api_token: str, device: str = '',
|
||||
sound: str = '', critical_priority: str = 'true'):
|
||||
super().__init__()
|
||||
self.user_key = (user_key or '').strip()
|
||||
self.api_token = (api_token or '').strip()
|
||||
self.device = (device or '').strip()
|
||||
self.sound = (sound or '').strip()
|
||||
self.critical_priority = str(critical_priority).lower() == 'true'
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.user_key:
|
||||
return False, 'Pushover user or group key is required'
|
||||
if not self.api_token:
|
||||
return False, 'Pushover application API token is required'
|
||||
if not self._CREDENTIAL_RE.fullmatch(self.user_key):
|
||||
return False, 'Invalid Pushover user or group key format'
|
||||
if not self._CREDENTIAL_RE.fullmatch(self.api_token):
|
||||
return False, 'Invalid Pushover application API token format'
|
||||
if self.device and not self._OPTION_RE.fullmatch(self.device):
|
||||
return False, 'Invalid Pushover device name format'
|
||||
if self.sound and not self._OPTION_RE.fullmatch(self.sound):
|
||||
return False, 'Invalid Pushover sound name format'
|
||||
return True, ''
|
||||
|
||||
@staticmethod
|
||||
def _truncate(value: str, limit: int) -> str:
|
||||
value = value or ''
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit - 1].rstrip() + '…'
|
||||
|
||||
@staticmethod
|
||||
def _response_error(body: str) -> str:
|
||||
try:
|
||||
payload = json.loads(body or '{}')
|
||||
errors = payload.get('errors')
|
||||
if isinstance(errors, list):
|
||||
clean = [str(item)[:160] for item in errors if item]
|
||||
if clean:
|
||||
return '; '.join(clean)
|
||||
if isinstance(errors, str) and errors:
|
||||
return errors[:200]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 'Pushover API rejected the request'
|
||||
|
||||
def _post_message(self, title: str, message: str,
|
||||
priority: int) -> Tuple[int, str]:
|
||||
payload = {
|
||||
'token': self.api_token,
|
||||
'user': self.user_key,
|
||||
'title': self._truncate(title, self.MAX_TITLE_LENGTH),
|
||||
'message': self._truncate(message, self.MAX_MESSAGE_LENGTH),
|
||||
'priority': str(priority),
|
||||
}
|
||||
if self.device:
|
||||
payload['device'] = self.device
|
||||
if self.sound:
|
||||
payload['sound'] = self.sound
|
||||
|
||||
body = urllib.parse.urlencode(payload).encode('utf-8')
|
||||
status, response_body = self._http_request(
|
||||
self.API_URL,
|
||||
body,
|
||||
{'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
)
|
||||
if 200 <= status < 300:
|
||||
try:
|
||||
response = json.loads(response_body or '{}')
|
||||
if response.get('status') == 1:
|
||||
return status, ''
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 400, self._response_error(response_body)
|
||||
return status, self._response_error(response_body)
|
||||
|
||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
valid, error = self.validate_config()
|
||||
if not valid:
|
||||
return {'success': False, 'error': error, 'channel': 'pushover'}
|
||||
|
||||
priority = (
|
||||
1
|
||||
if self.critical_priority and str(severity or '').upper() == 'CRITICAL'
|
||||
else 0
|
||||
)
|
||||
result = self._send_with_retry(
|
||||
lambda: self._post_message(title, message, priority)
|
||||
)
|
||||
result['channel'] = 'pushover'
|
||||
return result
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
result = self.send(
|
||||
'ProxMenux Test',
|
||||
'Pushover is configured correctly. This is a test message from ProxMenux Monitor.',
|
||||
'INFO',
|
||||
)
|
||||
return result['success'], result.get('error', '')
|
||||
|
||||
|
||||
# ─── Discord ─────────────────────────────────────────────────────
|
||||
|
||||
class DiscordChannel(NotificationChannel):
|
||||
@@ -1189,7 +1304,7 @@ class AppriseChannel(NotificationChannel):
|
||||
Apprise (https://github.com/caronc/apprise) is a Python library that
|
||||
normalises a wide catalogue of notification destinations behind a
|
||||
single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`,
|
||||
`ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc.
|
||||
`ntfy://`, `matrix://`, `mailto://`, `pover://`, `signal://`, etc.
|
||||
The operator pastes one URL and ProxMenux delegates the transport.
|
||||
|
||||
Requested in issue #207 by @0berkampf. Implemented as a *separate
|
||||
@@ -1347,6 +1462,13 @@ CHANNEL_TYPES = {
|
||||
'from_address', 'to_addresses', 'subject_prefix'],
|
||||
'class': EmailChannel,
|
||||
},
|
||||
'pushover': {
|
||||
'name': 'Pushover',
|
||||
'config_keys': ['user_key', 'api_token', 'device', 'sound',
|
||||
'critical_priority'],
|
||||
'required_keys': ['user_key', 'api_token'],
|
||||
'class': PushoverChannel,
|
||||
},
|
||||
'apprise': {
|
||||
'name': 'Apprise',
|
||||
'config_keys': ['url'],
|
||||
@@ -1359,7 +1481,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
||||
"""Create a channel instance from type name and config dict.
|
||||
|
||||
Args:
|
||||
channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
|
||||
channel_type: 'telegram', 'gotify', 'discord', 'email', 'pushover',
|
||||
or 'apprise'
|
||||
config: Dict with channel-specific keys (see CHANNEL_TYPES)
|
||||
|
||||
Returns:
|
||||
@@ -1383,6 +1506,14 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
||||
)
|
||||
elif channel_type == 'email':
|
||||
return EmailChannel(config)
|
||||
elif channel_type == 'pushover':
|
||||
return PushoverChannel(
|
||||
user_key=config.get('user_key', ''),
|
||||
api_token=config.get('api_token', ''),
|
||||
device=config.get('device', ''),
|
||||
sound=config.get('sound', ''),
|
||||
critical_priority=config.get('critical_priority', 'true'),
|
||||
)
|
||||
elif channel_type == 'apprise':
|
||||
return AppriseChannel(url=config.get('url', ''))
|
||||
except Exception as e:
|
||||
|
||||
@@ -3817,21 +3817,13 @@ class PollingCollector:
|
||||
return 'secure_gateway_update_available', data
|
||||
|
||||
if item_type == 'nvidia_xfree86':
|
||||
kind = update.get('_upgrade_kind')
|
||||
if kind == 'branch_upgrade':
|
||||
upgrade_reason = (
|
||||
"Your current driver branch is no longer compatible with "
|
||||
f"kernel {update.get('_kernel') or 'this kernel'}. "
|
||||
"Switch to the recommended branch — the installer will "
|
||||
"rebuild against the running kernel."
|
||||
)
|
||||
else:
|
||||
upgrade_reason = (
|
||||
"Same-branch maintenance update with bug/security fixes."
|
||||
)
|
||||
upgrade_reason = (
|
||||
"Same-branch maintenance update with bug/security fixes. "
|
||||
"The installer validates the selected release by rebuilding "
|
||||
"its DKMS module against the running kernel."
|
||||
)
|
||||
data = {
|
||||
**common,
|
||||
'kernel': update.get('_kernel') or '',
|
||||
'upgrade_reason': upgrade_reason,
|
||||
}
|
||||
return 'nvidia_driver_update_available', data
|
||||
|
||||
@@ -3,7 +3,8 @@ ProxMenux Notification Manager
|
||||
Central orchestrator for the notification service.
|
||||
|
||||
Connects:
|
||||
- notification_channels.py (transport: Telegram, Gotify, Discord)
|
||||
- notification_channels.py (transport: Telegram, Gotify, Discord, Email,
|
||||
Pushover, Apprise)
|
||||
- notification_templates.py (message formatting + optional AI)
|
||||
- notification_events.py (event detection: Journal, Task, Polling watchers)
|
||||
- health_persistence.py (DB: config storage, notification_history)
|
||||
@@ -79,6 +80,8 @@ SENSITIVE_KEYS = {
|
||||
'gotify.token',
|
||||
'discord.webhook_url',
|
||||
'email.password',
|
||||
'pushover.user_key',
|
||||
'pushover.api_token',
|
||||
'apprise.url',
|
||||
'webhook_secret',
|
||||
}
|
||||
@@ -2520,9 +2523,10 @@ class NotificationManager:
|
||||
channels_info = {}
|
||||
for ch_type, info in CHANNEL_TYPES.items():
|
||||
enabled = self._config.get(f'{ch_type}.enabled', 'false') == 'true'
|
||||
required_keys = info.get('required_keys', info['config_keys'])
|
||||
configured = all(
|
||||
bool(self._config.get(f'{ch_type}.{k}', ''))
|
||||
for k in info['config_keys']
|
||||
for k in required_keys
|
||||
)
|
||||
channels_info[ch_type] = {
|
||||
'name': info['name'],
|
||||
|
||||
@@ -1318,7 +1318,7 @@ TEMPLATES = {
|
||||
'nvidia_driver_update_available': {
|
||||
'title': '{hostname}: NVIDIA driver update available — v{latest_version}',
|
||||
'body': (
|
||||
'A newer NVIDIA driver compatible with kernel {kernel} is available.\n'
|
||||
'A newer maintenance release is available for the installed NVIDIA driver branch.\n'
|
||||
'🔹 Currently installed: v{current_version}\n'
|
||||
'🟢 Latest available: v{latest_version}\n\n'
|
||||
'{upgrade_reason}\n\n'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from notification_channels import PushoverChannel, create_channel
|
||||
|
||||
|
||||
class PushoverChannelTests(unittest.TestCase):
|
||||
USER_KEY = "u" * 30
|
||||
API_TOKEN = "a" * 30
|
||||
|
||||
def make_channel(self, **kwargs):
|
||||
channel = PushoverChannel(
|
||||
user_key=kwargs.pop("user_key", self.USER_KEY),
|
||||
api_token=kwargs.pop("api_token", self.API_TOKEN),
|
||||
**kwargs,
|
||||
)
|
||||
channel.MAX_RETRIES = 1
|
||||
return channel
|
||||
|
||||
def test_requires_valid_user_key_and_api_token(self):
|
||||
channel = self.make_channel(user_key="")
|
||||
self.assertEqual(channel.validate_config(), (
|
||||
False, "Pushover user or group key is required"
|
||||
))
|
||||
|
||||
channel = self.make_channel(api_token="short")
|
||||
self.assertEqual(channel.validate_config(), (
|
||||
False, "Invalid Pushover application API token format"
|
||||
))
|
||||
|
||||
def test_optional_device_sound_and_factory(self):
|
||||
channel = create_channel("pushover", {
|
||||
"user_key": self.USER_KEY,
|
||||
"api_token": self.API_TOKEN,
|
||||
"device": "iphone_15",
|
||||
"sound": "magic",
|
||||
"critical_priority": "false",
|
||||
})
|
||||
|
||||
self.assertIsInstance(channel, PushoverChannel)
|
||||
self.assertEqual(channel.validate_config(), (True, ""))
|
||||
self.assertFalse(channel.critical_priority)
|
||||
|
||||
def test_critical_alert_uses_high_priority_and_api_limits(self):
|
||||
channel = self.make_channel(
|
||||
device="iphone_15",
|
||||
sound="magic",
|
||||
critical_priority="true",
|
||||
)
|
||||
request = {}
|
||||
|
||||
def fake_http(url, data, headers):
|
||||
request["url"] = url
|
||||
request["payload"] = urllib.parse.parse_qs(data.decode("utf-8"))
|
||||
request["headers"] = headers
|
||||
return 200, '{"status":1,"request":"test"}'
|
||||
|
||||
channel._http_request = fake_http
|
||||
result = channel.send("T" * 300, "M" * 1200, "critical")
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(request["url"], PushoverChannel.API_URL)
|
||||
self.assertEqual(request["payload"]["priority"], ["1"])
|
||||
self.assertEqual(request["payload"]["device"], ["iphone_15"])
|
||||
self.assertEqual(request["payload"]["sound"], ["magic"])
|
||||
self.assertEqual(len(request["payload"]["title"][0]), 250)
|
||||
self.assertEqual(len(request["payload"]["message"][0]), 1024)
|
||||
self.assertTrue(request["payload"]["message"][0].endswith("…"))
|
||||
|
||||
def test_noncritical_alert_uses_normal_priority(self):
|
||||
channel = self.make_channel(critical_priority="true")
|
||||
request = {}
|
||||
|
||||
def fake_http(url, data, headers):
|
||||
request["payload"] = urllib.parse.parse_qs(data.decode("utf-8"))
|
||||
return 200, '{"status":1}'
|
||||
|
||||
channel._http_request = fake_http
|
||||
result = channel.send("Warning", "Message", "WARNING")
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(request["payload"]["priority"], ["0"])
|
||||
|
||||
def test_api_error_does_not_expose_credentials(self):
|
||||
channel = self.make_channel()
|
||||
channel._http_request = lambda *args: (
|
||||
400, '{"status":0,"errors":["user identifier is invalid"]}'
|
||||
)
|
||||
|
||||
result = channel.send("Title", "Message")
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("user identifier is invalid", result["error"])
|
||||
self.assertNotIn(self.USER_KEY, result["error"])
|
||||
self.assertNotIn(self.API_TOKEN, result["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user