mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-20 17:36:24 +00:00
Update notification service
This commit is contained in:
@@ -12,7 +12,8 @@ import { fetchApi } from "../lib/api-config"
|
|||||||
import {
|
import {
|
||||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||||
Trash2, ChevronDown, ChevronUp, TestTube2, Mail, Webhook
|
Trash2, ChevronDown, ChevronUp, TestTube2, Mail, Webhook,
|
||||||
|
Copy, Server, Shield
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
interface ChannelConfig {
|
interface ChannelConfig {
|
||||||
@@ -45,6 +46,9 @@ interface NotificationConfig {
|
|||||||
hostname: string
|
hostname: string
|
||||||
webhook_secret: string
|
webhook_secret: string
|
||||||
webhook_allowed_ips: string
|
webhook_allowed_ips: string
|
||||||
|
pbs_host: string
|
||||||
|
pve_host: string
|
||||||
|
pbs_trusted_sources: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceStatus {
|
interface ServiceStatus {
|
||||||
@@ -109,6 +113,9 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
hostname: "",
|
hostname: "",
|
||||||
webhook_secret: "",
|
webhook_secret: "",
|
||||||
webhook_allowed_ips: "",
|
webhook_allowed_ips: "",
|
||||||
|
pbs_host: "",
|
||||||
|
pve_host: "",
|
||||||
|
pbs_trusted_sources: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
@@ -198,6 +205,18 @@ export function NotificationSettings() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
// If notifications are being disabled, clean up PVE webhook first
|
||||||
|
const wasEnabled = originalConfig.enabled
|
||||||
|
const isNowDisabled = !config.enabled
|
||||||
|
|
||||||
|
if (wasEnabled && isNowDisabled) {
|
||||||
|
try {
|
||||||
|
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: webhook cleanup failed but we still save settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await fetchApi("/api/notifications/settings", {
|
await fetchApi("/api/notifications/settings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
@@ -581,9 +600,8 @@ matcher: proxmenux-pbs
|
|||||||
<button
|
<button
|
||||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
config.channels.telegram?.enabled ? "bg-blue-600" : "bg-muted-foreground/30"
|
config.channels.telegram?.enabled ? "bg-blue-600" : "bg-muted-foreground/30"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} cursor-pointer`}
|
||||||
onClick={() => editMode && updateChannel("telegram", "enabled", !config.channels.telegram?.enabled)}
|
onClick={() => updateChannel("telegram", "enabled", !config.channels.telegram?.enabled)}
|
||||||
disabled={!editMode}
|
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={config.channels.telegram?.enabled || false}
|
aria-checked={config.channels.telegram?.enabled || false}
|
||||||
>
|
>
|
||||||
@@ -603,7 +621,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="123456:ABC-DEF1234..."
|
placeholder="123456:ABC-DEF1234..."
|
||||||
value={config.channels.telegram?.bot_token || ""}
|
value={config.channels.telegram?.bot_token || ""}
|
||||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||||
@@ -620,23 +637,27 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="-1001234567890"
|
placeholder="-1001234567890"
|
||||||
value={config.channels.telegram?.chat_id || ""}
|
value={config.channels.telegram?.chat_id || ""}
|
||||||
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && config.channels.telegram?.bot_token && (
|
{/* Per-channel action bar */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<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 w-full justify-center"
|
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={() => handleTest("telegram")}
|
onClick={handleSave}
|
||||||
disabled={testing === "telegram"}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{testing === "telegram" ? (
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
Save
|
||||||
) : (
|
|
||||||
<TestTube2 className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Test Telegram
|
|
||||||
</button>
|
</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 disabled:opacity-50"
|
||||||
|
onClick={() => handleTest("telegram")}
|
||||||
|
disabled={testing === "telegram" || !config.channels.telegram?.bot_token}
|
||||||
|
>
|
||||||
|
{testing === "telegram" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||||
|
Send Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -648,9 +669,8 @@ matcher: proxmenux-pbs
|
|||||||
<button
|
<button
|
||||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
config.channels.gotify?.enabled ? "bg-green-600" : "bg-muted-foreground/30"
|
config.channels.gotify?.enabled ? "bg-green-600" : "bg-muted-foreground/30"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} cursor-pointer`}
|
||||||
onClick={() => editMode && updateChannel("gotify", "enabled", !config.channels.gotify?.enabled)}
|
onClick={() => updateChannel("gotify", "enabled", !config.channels.gotify?.enabled)}
|
||||||
disabled={!editMode}
|
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={config.channels.gotify?.enabled || false}
|
aria-checked={config.channels.gotify?.enabled || false}
|
||||||
>
|
>
|
||||||
@@ -668,7 +688,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="https://gotify.example.com"
|
placeholder="https://gotify.example.com"
|
||||||
value={config.channels.gotify?.url || ""}
|
value={config.channels.gotify?.url || ""}
|
||||||
onChange={e => updateChannel("gotify", "url", e.target.value)}
|
onChange={e => updateChannel("gotify", "url", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -680,7 +699,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="A_valid_gotify_token"
|
placeholder="A_valid_gotify_token"
|
||||||
value={config.channels.gotify?.token || ""}
|
value={config.channels.gotify?.token || ""}
|
||||||
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||||
@@ -690,20 +708,25 @@ matcher: proxmenux-pbs
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && config.channels.gotify?.url && (
|
{/* Per-channel action bar */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<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 w-full justify-center"
|
className="h-7 px-3 text-xs rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
onClick={() => handleTest("gotify")}
|
onClick={handleSave}
|
||||||
disabled={testing === "gotify"}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{testing === "gotify" ? (
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
Save
|
||||||
) : (
|
|
||||||
<TestTube2 className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Test Gotify
|
|
||||||
</button>
|
</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 disabled:opacity-50"
|
||||||
|
onClick={() => handleTest("gotify")}
|
||||||
|
disabled={testing === "gotify" || !config.channels.gotify?.url}
|
||||||
|
>
|
||||||
|
{testing === "gotify" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||||
|
Send Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -715,9 +738,8 @@ matcher: proxmenux-pbs
|
|||||||
<button
|
<button
|
||||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
config.channels.discord?.enabled ? "bg-indigo-600" : "bg-muted-foreground/30"
|
config.channels.discord?.enabled ? "bg-indigo-600" : "bg-muted-foreground/30"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} cursor-pointer`}
|
||||||
onClick={() => editMode && updateChannel("discord", "enabled", !config.channels.discord?.enabled)}
|
onClick={() => updateChannel("discord", "enabled", !config.channels.discord?.enabled)}
|
||||||
disabled={!editMode}
|
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={config.channels.discord?.enabled || false}
|
aria-checked={config.channels.discord?.enabled || false}
|
||||||
>
|
>
|
||||||
@@ -737,7 +759,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
value={config.channels.discord?.webhook_url || ""}
|
value={config.channels.discord?.webhook_url || ""}
|
||||||
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||||
@@ -747,20 +768,25 @@ matcher: proxmenux-pbs
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && config.channels.discord?.webhook_url && (
|
{/* Per-channel action bar */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<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 w-full justify-center"
|
className="h-7 px-3 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
onClick={() => handleTest("discord")}
|
onClick={handleSave}
|
||||||
disabled={testing === "discord"}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{testing === "discord" ? (
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
Save
|
||||||
) : (
|
|
||||||
<TestTube2 className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Test Discord
|
|
||||||
</button>
|
</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 disabled:opacity-50"
|
||||||
|
onClick={() => handleTest("discord")}
|
||||||
|
disabled={testing === "discord" || !config.channels.discord?.webhook_url}
|
||||||
|
>
|
||||||
|
{testing === "discord" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||||
|
Send Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -772,9 +798,8 @@ matcher: proxmenux-pbs
|
|||||||
<button
|
<button
|
||||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
config.channels.email?.enabled ? "bg-amber-600" : "bg-muted-foreground/30"
|
config.channels.email?.enabled ? "bg-amber-600" : "bg-muted-foreground/30"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} cursor-pointer`}
|
||||||
onClick={() => editMode && updateChannel("email", "enabled", !config.channels.email?.enabled)}
|
onClick={() => updateChannel("email", "enabled", !config.channels.email?.enabled)}
|
||||||
disabled={!editMode}
|
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={config.channels.email?.enabled || false}
|
aria-checked={config.channels.email?.enabled || false}
|
||||||
>
|
>
|
||||||
@@ -793,7 +818,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="smtp.gmail.com"
|
placeholder="smtp.gmail.com"
|
||||||
value={config.channels.email?.host || ""}
|
value={config.channels.email?.host || ""}
|
||||||
onChange={e => updateChannel("email", "host", e.target.value)}
|
onChange={e => updateChannel("email", "host", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -803,7 +827,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="587"
|
placeholder="587"
|
||||||
value={config.channels.email?.port || ""}
|
value={config.channels.email?.port || ""}
|
||||||
onChange={e => updateChannel("email", "port", e.target.value)}
|
onChange={e => updateChannel("email", "port", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -812,9 +835,8 @@ matcher: proxmenux-pbs
|
|||||||
<Select
|
<Select
|
||||||
value={config.channels.email?.tls_mode || "starttls"}
|
value={config.channels.email?.tls_mode || "starttls"}
|
||||||
onValueChange={v => updateChannel("email", "tls_mode", v)}
|
onValueChange={v => updateChannel("email", "tls_mode", v)}
|
||||||
disabled={!editMode}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className={`h-7 text-xs ${!editMode ? "opacity-60" : ""}`}>
|
<SelectTrigger className="h-7 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -832,7 +854,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
value={config.channels.email?.username || ""}
|
value={config.channels.email?.username || ""}
|
||||||
onChange={e => updateChannel("email", "username", e.target.value)}
|
onChange={e => updateChannel("email", "username", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -844,7 +865,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="App password"
|
placeholder="App password"
|
||||||
value={config.channels.email?.password || ""}
|
value={config.channels.email?.password || ""}
|
||||||
onChange={e => updateChannel("email", "password", e.target.value)}
|
onChange={e => updateChannel("email", "password", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||||
@@ -862,7 +882,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="proxmenux@yourdomain.com"
|
placeholder="proxmenux@yourdomain.com"
|
||||||
value={config.channels.email?.from_address || ""}
|
value={config.channels.email?.from_address || ""}
|
||||||
onChange={e => updateChannel("email", "from_address", e.target.value)}
|
onChange={e => updateChannel("email", "from_address", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -872,7 +891,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="admin@example.com, ops@example.com"
|
placeholder="admin@example.com, ops@example.com"
|
||||||
value={config.channels.email?.to_addresses || ""}
|
value={config.channels.email?.to_addresses || ""}
|
||||||
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
|
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -882,7 +900,6 @@ matcher: proxmenux-pbs
|
|||||||
placeholder="[ProxMenux]"
|
placeholder="[ProxMenux]"
|
||||||
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
|
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
|
||||||
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
|
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
|
||||||
disabled={!editMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
|
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||||
@@ -892,20 +909,25 @@ matcher: proxmenux-pbs
|
|||||||
For Gmail, use an App Password instead of your account password.
|
For Gmail, use an App Password instead of your account password.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && config.channels.email?.to_addresses && (
|
{/* Per-channel action bar */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<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 w-full justify-center"
|
className="h-7 px-3 text-xs rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
onClick={() => handleTest("email")}
|
onClick={handleSave}
|
||||||
disabled={testing === "email"}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{testing === "email" ? (
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
Save
|
||||||
) : (
|
|
||||||
<TestTube2 className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Test Email
|
|
||||||
</button>
|
</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 disabled:opacity-50"
|
||||||
|
onClick={() => handleTest("email")}
|
||||||
|
disabled={testing === "email" || !config.channels.email?.to_addresses}
|
||||||
|
>
|
||||||
|
{testing === "email" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||||
|
Send Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -929,7 +951,7 @@ matcher: proxmenux-pbs
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Severity Filter ── */}
|
{/* ── Severity Filter ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 border-t border-border pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
|
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Severity Filter</span>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Severity Filter</span>
|
||||||
@@ -951,7 +973,7 @@ matcher: proxmenux-pbs
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Event Categories ── */}
|
{/* ── Event Categories ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 border-t border-border pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Send className="h-3.5 w-3.5 text-muted-foreground" />
|
<Send className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Event Categories</span>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Event Categories</span>
|
||||||
|
|||||||
@@ -157,6 +157,78 @@ def send_notification():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ── PVE config constants ──
|
||||||
|
_PVE_ENDPOINT_ID = 'proxmenux-webhook'
|
||||||
|
_PVE_MATCHER_ID = 'proxmenux-default'
|
||||||
|
_PVE_WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||||
|
_PVE_NOTIFICATIONS_CFG = '/etc/pve/notifications.cfg'
|
||||||
|
_PVE_PRIV_CFG = '/etc/pve/priv/notifications.cfg'
|
||||||
|
_PVE_OUR_HEADERS = {
|
||||||
|
f'webhook: {_PVE_ENDPOINT_ID}',
|
||||||
|
f'matcher: {_PVE_MATCHER_ID}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pve_read_file(path):
|
||||||
|
"""Read file, return (content, error). Content is '' if missing."""
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return f.read(), None
|
||||||
|
except FileNotFoundError:
|
||||||
|
return '', None
|
||||||
|
except PermissionError:
|
||||||
|
return None, f'Permission denied reading {path}'
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _pve_backup_file(path):
|
||||||
|
"""Create timestamped backup if file exists. Never fails fatally."""
|
||||||
|
import os, shutil
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup = f"{path}.proxmenux_backup_{ts}"
|
||||||
|
shutil.copy2(path, backup)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _pve_remove_our_blocks(text, headers_to_remove):
|
||||||
|
"""Remove only blocks whose header line matches one of ours.
|
||||||
|
|
||||||
|
Preserves ALL other content byte-for-byte.
|
||||||
|
A block = header line + indented continuation lines + trailing blank line.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
cleaned = []
|
||||||
|
skip_block = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped and not line[0:1].isspace() and ':' in stripped:
|
||||||
|
if stripped in headers_to_remove:
|
||||||
|
skip_block = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
skip_block = False
|
||||||
|
|
||||||
|
if skip_block:
|
||||||
|
if not stripped:
|
||||||
|
skip_block = False
|
||||||
|
continue
|
||||||
|
elif line[0:1].isspace():
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
skip_block = False
|
||||||
|
|
||||||
|
cleaned.append(line)
|
||||||
|
|
||||||
|
return ''.join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
@notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST'])
|
@notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST'])
|
||||||
def setup_proxmox_webhook():
|
def setup_proxmox_webhook():
|
||||||
"""Automatically configure PVE notifications to call our webhook.
|
"""Automatically configure PVE notifications to call our webhook.
|
||||||
@@ -168,100 +240,31 @@ def setup_proxmox_webhook():
|
|||||||
Idempotent: safe to call multiple times.
|
Idempotent: safe to call multiple times.
|
||||||
Only touches blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
Only touches blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import secrets as secrets_mod
|
import secrets as secrets_mod
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
ENDPOINT_ID = 'proxmenux-webhook'
|
|
||||||
MATCHER_ID = 'proxmenux-default'
|
|
||||||
WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook'
|
|
||||||
NOTIFICATIONS_CFG = '/etc/pve/notifications.cfg'
|
|
||||||
PRIV_CFG = '/etc/pve/priv/notifications.cfg'
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'configured': False,
|
'configured': False,
|
||||||
'endpoint_id': ENDPOINT_ID,
|
'endpoint_id': _PVE_ENDPOINT_ID,
|
||||||
'matcher_id': MATCHER_ID,
|
'matcher_id': _PVE_MATCHER_ID,
|
||||||
'url': WEBHOOK_URL,
|
'url': _PVE_WEBHOOK_URL,
|
||||||
'fallback_commands': [],
|
'fallback_commands': [],
|
||||||
'error': None,
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_fallback():
|
def _build_fallback():
|
||||||
"""Build manual instructions as fallback."""
|
|
||||||
return [
|
return [
|
||||||
"# Append to END of /etc/pve/notifications.cfg",
|
"# Append to END of /etc/pve/notifications.cfg",
|
||||||
"# (do NOT delete existing content):",
|
"# (do NOT delete existing content):",
|
||||||
"",
|
"",
|
||||||
f"webhook: {ENDPOINT_ID}",
|
f"webhook: {_PVE_ENDPOINT_ID}",
|
||||||
f"\tmethod post",
|
f"\tmethod post",
|
||||||
f"\turl {WEBHOOK_URL}",
|
f"\turl {_PVE_WEBHOOK_URL}",
|
||||||
"",
|
"",
|
||||||
f"matcher: {MATCHER_ID}",
|
f"matcher: {_PVE_MATCHER_ID}",
|
||||||
f"\ttarget {ENDPOINT_ID}",
|
f"\ttarget {_PVE_ENDPOINT_ID}",
|
||||||
"\tmatch-severity warning,error",
|
"\tmatch-severity warning,error",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _read_file(path):
|
|
||||||
"""Read file, return (content, error). Content is '' if missing."""
|
|
||||||
try:
|
|
||||||
with open(path, 'r') as f:
|
|
||||||
return f.read(), None
|
|
||||||
except FileNotFoundError:
|
|
||||||
return '', None
|
|
||||||
except PermissionError:
|
|
||||||
return None, f'Permission denied reading {path}'
|
|
||||||
except Exception as e:
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
def _backup_file(path):
|
|
||||||
"""Create timestamped backup if file exists. Never fails fatally."""
|
|
||||||
try:
|
|
||||||
if os.path.exists(path):
|
|
||||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
backup = f"{path}.proxmenux_backup_{ts}"
|
|
||||||
shutil.copy2(path, backup)
|
|
||||||
except Exception:
|
|
||||||
pass # Best-effort backup
|
|
||||||
|
|
||||||
def _remove_our_blocks(text, headers_to_remove):
|
|
||||||
"""Remove only blocks whose header line matches one of ours.
|
|
||||||
|
|
||||||
Preserves ALL other content byte-for-byte.
|
|
||||||
A block = header line + indented continuation lines + trailing blank line.
|
|
||||||
"""
|
|
||||||
lines = text.splitlines(keepends=True)
|
|
||||||
cleaned = []
|
|
||||||
skip_block = False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
# Non-whitespace line with ':' => block header
|
|
||||||
if stripped and not line[0:1].isspace() and ':' in stripped:
|
|
||||||
if stripped in headers_to_remove:
|
|
||||||
skip_block = True
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
skip_block = False
|
|
||||||
|
|
||||||
if skip_block:
|
|
||||||
if not stripped:
|
|
||||||
# Blank line = block separator, consume it and stop skipping
|
|
||||||
skip_block = False
|
|
||||||
continue
|
|
||||||
elif line[0:1].isspace():
|
|
||||||
# Indented = continuation of our block
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# New block started
|
|
||||||
skip_block = False
|
|
||||||
|
|
||||||
cleaned.append(line)
|
|
||||||
|
|
||||||
return ''.join(cleaned)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
|
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
|
||||||
secret = notification_manager.get_webhook_secret()
|
secret = notification_manager.get_webhook_secret()
|
||||||
@@ -270,32 +273,27 @@ def setup_proxmox_webhook():
|
|||||||
notification_manager._save_setting('webhook_secret', secret)
|
notification_manager._save_setting('webhook_secret', secret)
|
||||||
|
|
||||||
# ── Step 2: Read main config ──
|
# ── Step 2: Read main config ──
|
||||||
cfg_text, err = _read_file(NOTIFICATIONS_CFG)
|
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
if err:
|
if err:
|
||||||
result['error'] = err
|
result['error'] = err
|
||||||
result['fallback_commands'] = _build_fallback()
|
result['fallback_commands'] = _build_fallback()
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
# ── Step 3: Read priv config (to clean up any broken blocks we wrote before) ──
|
# ── Step 3: Read priv config (to clean up any broken blocks we wrote before) ──
|
||||||
priv_text, err = _read_file(PRIV_CFG)
|
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
|
||||||
if err:
|
if err:
|
||||||
priv_text = None # Non-fatal, we just won't clean it
|
priv_text = None
|
||||||
|
|
||||||
# ── Step 4: Create backups before ANY modification ──
|
# ── Step 4: Create backups before ANY modification ──
|
||||||
_backup_file(NOTIFICATIONS_CFG)
|
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
if priv_text is not None:
|
if priv_text is not None:
|
||||||
_backup_file(PRIV_CFG)
|
_pve_backup_file(_PVE_PRIV_CFG)
|
||||||
|
|
||||||
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
||||||
our_headers = {
|
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
|
||||||
f'webhook: {ENDPOINT_ID}',
|
|
||||||
f'matcher: {MATCHER_ID}',
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned_cfg = _remove_our_blocks(cfg_text, our_headers)
|
|
||||||
|
|
||||||
if priv_text is not None:
|
if priv_text is not None:
|
||||||
cleaned_priv = _remove_our_blocks(priv_text, our_headers)
|
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
|
||||||
|
|
||||||
# ── Step 6: Build new blocks ──
|
# ── Step 6: Build new blocks ──
|
||||||
# Exact format from a real working PVE server:
|
# Exact format from a real working PVE server:
|
||||||
@@ -309,19 +307,18 @@ def setup_proxmox_webhook():
|
|||||||
# Neither is needed for localhost calls.
|
# Neither is needed for localhost calls.
|
||||||
|
|
||||||
endpoint_block = (
|
endpoint_block = (
|
||||||
f"webhook: {ENDPOINT_ID}\n"
|
f"webhook: {_PVE_ENDPOINT_ID}\n"
|
||||||
f"\tmethod post\n"
|
f"\tmethod post\n"
|
||||||
f"\turl {WEBHOOK_URL}\n"
|
f"\turl {_PVE_WEBHOOK_URL}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
matcher_block = (
|
matcher_block = (
|
||||||
f"matcher: {MATCHER_ID}\n"
|
f"matcher: {_PVE_MATCHER_ID}\n"
|
||||||
f"\ttarget {ENDPOINT_ID}\n"
|
f"\ttarget {_PVE_ENDPOINT_ID}\n"
|
||||||
f"\tmatch-severity warning,error\n"
|
f"\tmatch-severity warning,error\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Step 7: Append our blocks to cleaned main config ──
|
# ── Step 7: Append our blocks to cleaned main config ──
|
||||||
# Ensure existing content ends cleanly
|
|
||||||
if cleaned_cfg and not cleaned_cfg.endswith('\n'):
|
if cleaned_cfg and not cleaned_cfg.endswith('\n'):
|
||||||
cleaned_cfg += '\n'
|
cleaned_cfg += '\n'
|
||||||
if cleaned_cfg and not cleaned_cfg.endswith('\n\n'):
|
if cleaned_cfg and not cleaned_cfg.endswith('\n\n'):
|
||||||
@@ -331,16 +328,15 @@ def setup_proxmox_webhook():
|
|||||||
|
|
||||||
# ── Step 8: Write main config ──
|
# ── Step 8: Write main config ──
|
||||||
try:
|
try:
|
||||||
with open(NOTIFICATIONS_CFG, 'w') as f:
|
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||||
f.write(new_cfg)
|
f.write(new_cfg)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
result['error'] = f'Permission denied writing {NOTIFICATIONS_CFG}'
|
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
||||||
result['fallback_commands'] = _build_fallback()
|
result['fallback_commands'] = _build_fallback()
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Rollback
|
|
||||||
try:
|
try:
|
||||||
with open(NOTIFICATIONS_CFG, 'w') as f:
|
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||||
f.write(cfg_text)
|
f.write(cfg_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -351,10 +347,10 @@ def setup_proxmox_webhook():
|
|||||||
# ── Step 9: Clean priv config (remove our broken blocks, write nothing new) ──
|
# ── Step 9: Clean priv config (remove our broken blocks, write nothing new) ──
|
||||||
if priv_text is not None and cleaned_priv != priv_text:
|
if priv_text is not None and cleaned_priv != priv_text:
|
||||||
try:
|
try:
|
||||||
with open(PRIV_CFG, 'w') as f:
|
with open(_PVE_PRIV_CFG, 'w') as f:
|
||||||
f.write(cleaned_priv)
|
f.write(cleaned_priv)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Non-fatal, priv cleanup is best-effort
|
pass
|
||||||
|
|
||||||
result['configured'] = True
|
result['configured'] = True
|
||||||
result['secret'] = secret
|
result['secret'] = secret
|
||||||
@@ -366,6 +362,78 @@ def setup_proxmox_webhook():
|
|||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
|
@notification_bp.route('/api/notifications/proxmox/cleanup-webhook', methods=['POST'])
|
||||||
|
def cleanup_proxmox_webhook():
|
||||||
|
"""Remove ProxMenux webhook blocks from PVE notification config.
|
||||||
|
|
||||||
|
Called when the notification service is disabled.
|
||||||
|
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
||||||
|
All other blocks are preserved byte-for-byte.
|
||||||
|
Creates backups before modification.
|
||||||
|
"""
|
||||||
|
result = {'cleaned': False, 'error': None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read both files
|
||||||
|
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
|
if err:
|
||||||
|
result['error'] = err
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
|
||||||
|
if err:
|
||||||
|
priv_text = None
|
||||||
|
|
||||||
|
# Check if our blocks actually exist before doing anything
|
||||||
|
has_our_blocks = any(
|
||||||
|
h in cfg_text for h in [f'webhook: {_PVE_ENDPOINT_ID}', f'matcher: {_PVE_MATCHER_ID}']
|
||||||
|
)
|
||||||
|
has_priv_blocks = priv_text and f'webhook: {_PVE_ENDPOINT_ID}' in priv_text
|
||||||
|
|
||||||
|
if not has_our_blocks and not has_priv_blocks:
|
||||||
|
result['cleaned'] = True
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
# Backup before modification
|
||||||
|
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
|
||||||
|
if priv_text is not None:
|
||||||
|
_pve_backup_file(_PVE_PRIV_CFG)
|
||||||
|
|
||||||
|
# Remove our blocks
|
||||||
|
if has_our_blocks:
|
||||||
|
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
|
||||||
|
try:
|
||||||
|
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||||
|
f.write(cleaned_cfg)
|
||||||
|
except PermissionError:
|
||||||
|
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
|
||||||
|
return jsonify(result), 200
|
||||||
|
except Exception as e:
|
||||||
|
# Rollback
|
||||||
|
try:
|
||||||
|
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
|
||||||
|
f.write(cfg_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result['error'] = str(e)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
if has_priv_blocks and priv_text is not None:
|
||||||
|
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
|
||||||
|
try:
|
||||||
|
with open(_PVE_PRIV_CFG, 'w') as f:
|
||||||
|
f.write(cleaned_priv)
|
||||||
|
except Exception:
|
||||||
|
pass # Best-effort
|
||||||
|
|
||||||
|
result['cleaned'] = True
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
@notification_bp.route('/api/notifications/proxmox/read-cfg', methods=['GET'])
|
@notification_bp.route('/api/notifications/proxmox/read-cfg', methods=['GET'])
|
||||||
def read_pve_notification_cfg():
|
def read_pve_notification_cfg():
|
||||||
"""Diagnostic: return raw content of PVE notification config files.
|
"""Diagnostic: return raw content of PVE notification config files.
|
||||||
|
|||||||
Reference in New Issue
Block a user