Update notification service

This commit is contained in:
MacRimi
2026-02-19 17:02:02 +01:00
parent 34d04e57dd
commit 7c5cdb9161
7 changed files with 1587 additions and 95 deletions

View File

@@ -12,7 +12,7 @@ import { fetchApi } from "../lib/api-config"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
Trash2, ChevronDown, ChevronUp, TestTube2
Trash2, ChevronDown, ChevronUp, TestTube2, Mail, Webhook
} from "lucide-react"
interface ChannelConfig {
@@ -22,6 +22,15 @@ interface ChannelConfig {
url?: string
token?: string
webhook_url?: string
// Email channel fields
host?: string
port?: string
username?: string
password?: string
tls_mode?: string
from_address?: string
to_addresses?: string
subject_prefix?: string
}
interface NotificationConfig {
@@ -34,6 +43,8 @@ interface NotificationConfig {
ai_api_key: string
ai_model: string
hostname: string
webhook_secret: string
webhook_allowed_ips: string
}
interface ServiceStatus {
@@ -84,6 +95,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
telegram: { enabled: false },
gotify: { enabled: false },
discord: { enabled: false },
email: { enabled: false },
},
severity_filter: "warning",
event_categories: {
@@ -95,6 +107,8 @@ const DEFAULT_CONFIG: NotificationConfig = {
ai_api_key: "",
ai_model: "",
hostname: "",
webhook_secret: "",
webhook_allowed_ips: "",
}
export function NotificationSettings() {
@@ -112,6 +126,11 @@ export function NotificationSettings() {
const [editMode, setEditMode] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [originalConfig, setOriginalConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed"
fallback_commands: string[]
error: string
}>({ status: "idle", fallback_commands: [], error: "" })
const loadConfig = useCallback(async () => {
try {
@@ -252,6 +271,184 @@ export function NotificationSettings() {
const activeChannels = Object.entries(config.channels).filter(([, ch]) => ch.enabled).length
const handleEnable = async () => {
setSaving(true)
setWebhookSetup({ status: "running", fallback_commands: [], error: "" })
try {
// 1) Save enabled=true
const newConfig = { ...config, enabled: true }
await fetchApi("/api/notifications/settings", {
method: "POST",
body: JSON.stringify(newConfig),
})
setConfig(newConfig)
setOriginalConfig(newConfig)
// 2) Auto-configure PVE webhook
try {
const setup = await fetchApi<{
configured: boolean
secret?: string
fallback_commands?: string[]
error?: string
}>("/api/notifications/proxmox/setup-webhook", { method: "POST" })
if (setup.configured) {
setWebhookSetup({ status: "success", fallback_commands: [], error: "" })
// Update secret in local config if one was generated
if (setup.secret) {
const updated = { ...newConfig, webhook_secret: setup.secret }
setConfig(updated)
setOriginalConfig(updated)
}
} else {
setWebhookSetup({
status: "failed",
fallback_commands: setup.fallback_commands || [],
error: setup.error || "Unknown error",
})
}
} catch {
setWebhookSetup({
status: "failed",
fallback_commands: [],
error: "Could not reach setup endpoint",
})
}
setEditMode(true)
loadStatus()
} catch (err) {
console.error("Failed to enable notifications:", err)
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
} finally {
setSaving(false)
}
}
// ── Disabled state: show activation card ──
if (!config.enabled && !editMode) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BellOff className="h-5 w-5 text-muted-foreground" />
<CardTitle>Notifications</CardTitle>
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
Disabled
</Badge>
</div>
<CardDescription>
Get real-time alerts about your Proxmox environment via Telegram, Discord, Gotify, or Email.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-col gap-3 p-4 bg-muted/50 rounded-lg border border-border">
<div className="flex items-start gap-3">
<Bell className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">Enable notification service</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Monitor system health, VM/CT events, backups, security alerts, and cluster status.
PVE webhook integration is configured automatically.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start gap-2">
<button
className="h-8 px-4 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors w-full sm:w-auto disabled:opacity-50 flex items-center justify-center gap-2"
onClick={handleEnable}
disabled={saving}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Bell className="h-3.5 w-3.5" />}
{saving ? "Configuring..." : "Enable Notifications"}
</button>
</div>
{/* Webhook setup result */}
{webhookSetup.status === "success" && (
<div className="flex items-start gap-2 p-2 rounded-md bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0 mt-0.5" />
<p className="text-[11px] text-green-400 leading-relaxed">
PVE webhook configured automatically. Proxmox will send notifications to ProxMenux.
</p>
</div>
)}
{webhookSetup.status === "failed" && (
<div className="space-y-2">
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-[11px] text-amber-400 leading-relaxed">
Automatic PVE configuration failed: {webhookSetup.error}
</p>
<p className="text-[10px] text-muted-foreground">
Notifications are enabled. Run the commands below on the PVE host to complete webhook setup.
</p>
</div>
</div>
{webhookSetup.fallback_commands.length > 0 && (
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{webhookSetup.fallback_commands.join('\n')}
</pre>
)}
</div>
)}
</div>
{/* PBS manual section (collapsible) */}
<details className="group">
<summary className="text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors flex items-center gap-1.5">
<ChevronDown className="h-3 w-3 group-open:rotate-180 transition-transform" />
<Webhook className="h-3 w-3" />
Configure PBS notifications (manual)
</summary>
<div className="mt-2 p-3 bg-muted/30 rounded-md border border-border space-y-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground leading-relaxed">
PVE backups launched from the PVE interface are covered automatically by the PVE webhook above.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
However, PBS has its own internal jobs (Verify, Prune, GC, Sync) that generate
separate notifications. These must be configured directly on the PBS server.
</p>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-medium text-muted-foreground">
Run on the PBS host:
</p>
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`# Create webhook endpoint on PBS
proxmox-backup-manager notification endpoint webhook create proxmenux-webhook \\
--url http://<PVE_HOST_IP>:8008/api/notifications/webhook \\
--header "X-Webhook-Secret=<YOUR_SECRET>"
# Create matcher to route PBS events
proxmox-backup-manager notification matcher create proxmenux-pbs \\
--target proxmenux-webhook \\
--match-severity warning,error`}
</pre>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<div className="text-[10px] text-blue-400/90 leading-relaxed space-y-1">
<p>
{"Replace <PVE_HOST_IP> with the IP address of this PVE node (not 127.0.0.1, unless PBS runs on the same host)."}
</p>
<p>
{"Replace <YOUR_SECRET> with the webhook secret shown in your notification settings."}
</p>
</div>
</div>
</div>
</details>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
@@ -302,7 +499,7 @@ export function NotificationSettings() {
</div>
</div>
<CardDescription>
Configure notification channels and event filters. Receive alerts via Telegram, Gotify, or Discord.
Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.
</CardDescription>
</CardHeader>
@@ -369,7 +566,7 @@ export function NotificationSettings() {
</div>
<Tabs defaultValue="telegram" className="w-full">
<TabsList className="w-full grid grid-cols-3 h-8">
<TabsList className="w-full grid grid-cols-4 h-8">
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
Telegram
</TabsTrigger>
@@ -379,6 +576,9 @@ export function NotificationSettings() {
<TabsTrigger value="discord" className="text-xs data-[state=active]:text-indigo-500">
Discord
</TabsTrigger>
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
Email
</TabsTrigger>
</TabsList>
{/* Telegram */}
@@ -571,6 +771,151 @@ export function NotificationSettings() {
</>
)}
</TabsContent>
{/* Email */}
<TabsContent value="email" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Enable Email</Label>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.email?.enabled ? "bg-amber-600" : "bg-muted-foreground/30"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && updateChannel("email", "enabled", !config.channels.email?.enabled)}
disabled={!editMode}
role="switch"
aria-checked={config.channels.email?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.email?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.email?.enabled && (
<>
<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">SMTP Host</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="smtp.gmail.com"
value={config.channels.email?.host || ""}
onChange={e => updateChannel("email", "host", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Port</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="587"
value={config.channels.email?.port || ""}
onChange={e => updateChannel("email", "port", e.target.value)}
disabled={!editMode}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">TLS Mode</Label>
<Select
value={config.channels.email?.tls_mode || "starttls"}
onValueChange={v => updateChannel("email", "tls_mode", v)}
disabled={!editMode}
>
<SelectTrigger className={`h-7 text-xs ${!editMode ? "opacity-60" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS (port 587)</SelectItem>
<SelectItem value="ssl">SSL/TLS (port 465)</SelectItem>
<SelectItem value="none">None (port 25)</SelectItem>
</SelectContent>
</Select>
</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">Username</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="user@example.com"
value={config.channels.email?.username || ""}
onChange={e => updateChannel("email", "username", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Password</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["em_pass"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="App password"
value={config.channels.email?.password || ""}
onChange={e => updateChannel("email", "password", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
onClick={() => toggleSecret("em_pass")}
>
{showSecrets["em_pass"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">From Address</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="proxmenux@yourdomain.com"
value={config.channels.email?.from_address || ""}
onChange={e => updateChannel("email", "from_address", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">To Addresses (comma-separated)</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="admin@example.com, ops@example.com"
value={config.channels.email?.to_addresses || ""}
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Subject Prefix</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="[ProxMenux]"
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-amber-400/90 leading-relaxed">
Leave SMTP Host empty to use local sendmail (must be installed on the server).
For Gmail, use an App Password instead of your account password.
</p>
</div>
{!editMode && config.channels.email?.to_addresses && (
<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"
onClick={() => handleTest("email")}
disabled={testing === "email"}
>
{testing === "email" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<TestTube2 className="h-3 w-3" />
)}
Test Email
</button>
)}
</>
)}
</TabsContent>
</Tabs>
{/* Test Result */}
@@ -647,6 +992,131 @@ export function NotificationSettings() {
</div>
</div>
{/* ── Proxmox Webhook ── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Proxmox Webhook</span>
</div>
{!editMode && (
<button
className="h-6 px-2.5 text-[10px] rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={async () => {
try {
setWebhookSetup({ status: "running", fallback_commands: [], error: "" })
const setup = await fetchApi<{
configured: boolean; secret?: string; fallback_commands?: string[]; error?: string
}>("/api/notifications/proxmox/setup-webhook", { method: "POST" })
if (setup.configured) {
setWebhookSetup({ status: "success", fallback_commands: [], error: "" })
if (setup.secret) {
const updated = { ...config, webhook_secret: setup.secret }
setConfig(updated)
setOriginalConfig(updated)
}
} else {
setWebhookSetup({ status: "failed", fallback_commands: setup.fallback_commands || [], error: setup.error || "" })
}
} catch {
setWebhookSetup({ status: "failed", fallback_commands: [], error: "Request failed" })
}
}}
disabled={webhookSetup.status === "running"}
>
{webhookSetup.status === "running" ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Webhook className="h-2.5 w-2.5" />}
Re-configure PVE
</button>
)}
</div>
{/* Setup status inline */}
{webhookSetup.status === "success" && (
<div className="flex items-center gap-2 p-1.5 rounded bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
<p className="text-[10px] text-green-400">PVE webhook configured successfully.</p>
</div>
)}
{webhookSetup.status === "failed" && (
<div className="space-y-1.5">
<div className="flex items-start gap-2 p-1.5 rounded bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-3 w-3 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-amber-400">PVE auto-config failed: {webhookSetup.error}</p>
</div>
{webhookSetup.fallback_commands.length > 0 && (
<pre className="text-[10px] bg-background p-1.5 rounded border border-border overflow-x-auto font-mono">
{webhookSetup.fallback_commands.join('\n')}
</pre>
)}
</div>
)}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Shared Secret</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["wh_secret"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="Required for webhook authentication"
value={config.webhook_secret || ""}
onChange={e => updateConfig(p => ({ ...p, webhook_secret: e.target.value }))}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
onClick={() => toggleSecret("wh_secret")}
>
{showSecrets["wh_secret"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{"Proxmox must send this value in the X-Webhook-Secret header. Auto-generated on first enable."}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Allowed IPs (optional, remote only)</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="10.0.0.5, 192.168.1.10 (empty = allow all)"
value={config.webhook_allowed_ips || ""}
onChange={e => updateConfig(p => ({ ...p, webhook_allowed_ips: e.target.value }))}
disabled={!editMode}
/>
<p className="text-[10px] text-muted-foreground">
{"Localhost (127.0.0.1) is always allowed. This restricts remote callers only."}
</p>
</div>
{/* PBS manual guide (collapsible) */}
<details className="group">
<summary className="text-[11px] font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors flex items-center gap-1.5 py-1">
<ChevronDown className="h-3 w-3 group-open:rotate-180 transition-transform" />
Configure PBS notifications (manual)
</summary>
<div className="mt-1.5 p-2.5 bg-muted/30 rounded-md border border-border space-y-2">
<p className="text-[11px] text-muted-foreground leading-relaxed">
Backups launched from PVE are covered by the PVE webhook. PBS internal jobs
(Verify, Prune, GC, Sync) require separate configuration on the PBS server.
</p>
<pre className="text-[10px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`# On the PBS host:
proxmox-backup-manager notification endpoint webhook \\
create proxmenux-webhook \\
--url http://<PVE_IP>:8008/api/notifications/webhook \\
--header "X-Webhook-Secret=<SECRET>"
proxmox-backup-manager notification matcher \\
create proxmenux-pbs \\
--target proxmenux-webhook \\
--match-severity warning,error`}
</pre>
<p className="text-[10px] text-muted-foreground">
{"Replace <PVE_IP> with this node's IP and <SECRET> with the webhook secret above."}
</p>
</div>
</details>
</div>
{/* ── Advanced: AI Enhancement ── */}
<div>
<button
@@ -818,7 +1288,7 @@ export function NotificationSettings() {
<p className="text-[11px] text-muted-foreground leading-relaxed">
{config.enabled
? "Notifications are active. Events matching your severity filter and category selection will be sent to configured channels."
: "Enable notifications to receive alerts about system events, health status changes, and security incidents via Telegram, Gotify, or Discord."}
: "Enable notifications to receive alerts about system events, health status changes, and security incidents via Telegram, Gotify, Discord, or Email."}
</p>
</div>
</CardContent>