Files
ProxMenux/AppImage/components/notification-settings.tsx

1385 lines
64 KiB
TypeScript
Raw Normal View History

2026-02-18 17:24:26 +01:00
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Checkbox } from "./ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
2026-02-19 19:56:20 +01:00
Trash2, ChevronDown, ChevronUp, TestTube2, Mail, Webhook,
Copy, Server, Shield
2026-02-18 17:24:26 +01:00
} from "lucide-react"
interface ChannelConfig {
enabled: boolean
bot_token?: string
chat_id?: string
url?: string
token?: string
webhook_url?: string
2026-02-19 17:02:02 +01:00
// Email channel fields
host?: string
port?: string
username?: string
password?: string
tls_mode?: string
from_address?: string
to_addresses?: string
subject_prefix?: string
2026-02-18 17:24:26 +01:00
}
interface NotificationConfig {
enabled: boolean
channels: Record<string, ChannelConfig>
severity_filter: string
event_categories: Record<string, boolean>
ai_enabled: boolean
ai_provider: string
ai_api_key: string
ai_model: string
hostname: string
2026-02-19 17:02:02 +01:00
webhook_secret: string
webhook_allowed_ips: string
2026-02-19 19:56:20 +01:00
pbs_host: string
pve_host: string
pbs_trusted_sources: string
2026-02-18 17:24:26 +01:00
}
interface ServiceStatus {
enabled: boolean
running: boolean
channels: Record<string, boolean>
queue_size: number
last_sent: string | null
total_sent_24h: number
}
interface HistoryEntry {
id: number
event_type: string
channel: string
title: string
severity: string
sent_at: string
success: boolean
error_message: string | null
}
const SEVERITY_OPTIONS = [
{ value: "critical", label: "Critical only" },
{ value: "warning", label: "Warning + Critical" },
{ value: "info", label: "All (Info + Warning + Critical)" },
]
const EVENT_CATEGORIES = [
{ key: "system", label: "System", desc: "Startup, shutdown, kernel events" },
{ key: "vm_ct", label: "VM / CT", desc: "Start, stop, crash, migration" },
{ key: "backup", label: "Backups", desc: "Backup start, complete, fail" },
{ key: "resources", label: "Resources", desc: "CPU, memory, temperature" },
{ key: "storage", label: "Storage", desc: "Disk space, I/O errors, SMART" },
{ key: "network", label: "Network", desc: "Connectivity, bond, latency" },
{ key: "security", label: "Security", desc: "Auth failures, fail2ban, firewall" },
{ key: "cluster", label: "Cluster", desc: "Quorum, split-brain, HA fencing" },
]
const AI_PROVIDERS = [
{ value: "openai", label: "OpenAI" },
{ value: "groq", label: "Groq" },
]
const DEFAULT_CONFIG: NotificationConfig = {
enabled: false,
channels: {
telegram: { enabled: false },
gotify: { enabled: false },
discord: { enabled: false },
2026-02-19 17:02:02 +01:00
email: { enabled: false },
2026-02-18 17:24:26 +01:00
},
severity_filter: "warning",
event_categories: {
system: true, vm_ct: true, backup: true, resources: true,
storage: true, network: true, security: true, cluster: true,
},
ai_enabled: false,
ai_provider: "openai",
ai_api_key: "",
ai_model: "",
hostname: "",
2026-02-19 17:02:02 +01:00
webhook_secret: "",
webhook_allowed_ips: "",
2026-02-19 19:56:20 +01:00
pbs_host: "",
pve_host: "",
pbs_trusted_sources: "",
2026-02-18 17:24:26 +01:00
}
export function NotificationSettings() {
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [status, setStatus] = useState<ServiceStatus | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
const [editMode, setEditMode] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [originalConfig, setOriginalConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
2026-02-19 17:02:02 +01:00
const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed"
fallback_commands: string[]
error: string
}>({ status: "idle", fallback_commands: [], error: "" })
2026-02-18 17:24:26 +01:00
const loadConfig = useCallback(async () => {
try {
const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings")
if (data.success && data.config) {
setConfig(data.config)
setOriginalConfig(data.config)
}
} catch (err) {
console.error("Failed to load notification settings:", err)
} finally {
setLoading(false)
}
}, [])
const loadStatus = useCallback(async () => {
try {
const data = await fetchApi<{ success: boolean } & ServiceStatus>("/api/notifications/status")
if (data.success) {
setStatus(data)
}
} catch {
// Service may not be running yet
}
}, [])
const loadHistory = useCallback(async () => {
try {
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
if (data.success) {
setHistory(data.history || [])
}
} catch {
// Ignore
}
}, [])
useEffect(() => {
loadConfig()
loadStatus()
}, [loadConfig, loadStatus])
useEffect(() => {
if (showHistory) loadHistory()
}, [showHistory, loadHistory])
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
setConfig(prev => {
const next = updater(prev)
setHasChanges(true)
return next
})
}
const updateChannel = (channel: string, field: string, value: string | boolean) => {
updateConfig(prev => ({
...prev,
channels: {
...prev.channels,
[channel]: { ...prev.channels[channel], [field]: value },
},
}))
}
2026-02-19 20:51:54 +01:00
/** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
const flat: Record<string, string> = {
enabled: String(cfg.enabled),
severity_filter: cfg.severity_filter,
ai_enabled: String(cfg.ai_enabled),
ai_provider: cfg.ai_provider,
ai_api_key: cfg.ai_api_key,
ai_model: cfg.ai_model,
hostname: cfg.hostname,
webhook_secret: cfg.webhook_secret,
webhook_allowed_ips: cfg.webhook_allowed_ips,
pbs_host: cfg.pbs_host,
pve_host: cfg.pve_host,
pbs_trusted_sources: cfg.pbs_trusted_sources,
}
// Flatten channels: { telegram: { enabled, bot_token, chat_id } } -> telegram.enabled, telegram.bot_token, ...
for (const [chName, chCfg] of Object.entries(cfg.channels)) {
for (const [field, value] of Object.entries(chCfg)) {
flat[`${chName}.${field}`] = String(value ?? "")
}
}
// Flatten event_categories: { system: true, backups: false } -> events.system, events.backups
for (const [cat, enabled] of Object.entries(cfg.event_categories)) {
flat[`events.${cat}`] = String(enabled)
}
return flat
}
2026-02-18 17:24:26 +01:00
const handleSave = async () => {
setSaving(true)
try {
2026-02-19 19:56:20 +01:00
// 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
}
}
2026-02-19 20:51:54 +01:00
const payload = flattenConfig(config)
2026-02-18 17:24:26 +01:00
await fetchApi("/api/notifications/settings", {
method: "POST",
2026-02-19 20:51:54 +01:00
body: JSON.stringify(payload),
2026-02-18 17:24:26 +01:00
})
setOriginalConfig(config)
setHasChanges(false)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
loadStatus()
} catch (err) {
console.error("Failed to save notification settings:", err)
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setConfig(originalConfig)
setHasChanges(false)
setEditMode(false)
}
const handleTest = async (channel: string) => {
setTesting(channel)
setTestResult(null)
try {
2026-02-19 20:51:54 +01:00
// Auto-save current config before testing so backend has latest channel data
const payload = flattenConfig(config)
await fetchApi("/api/notifications/settings", {
method: "POST",
body: JSON.stringify(payload),
})
setOriginalConfig(config)
setHasChanges(false)
2026-02-19 20:30:11 +01:00
const data = await fetchApi<{
success: boolean
message?: string
error?: string
results?: Record<string, { success: boolean; error?: string | null }>
}>("/api/notifications/test", {
2026-02-18 17:24:26 +01:00
method: "POST",
body: JSON.stringify({ channel }),
})
2026-02-19 20:30:11 +01:00
// Extract message from the results object if present
let message = data.message || ""
if (!message && data.results) {
const channelResult = data.results[channel]
if (channelResult) {
message = channelResult.success
? "Test notification sent successfully"
: channelResult.error || "Test failed"
}
}
if (!message && data.error) {
message = data.error
}
if (!message) {
message = data.success ? "Test notification sent successfully" : "Test failed"
}
setTestResult({ channel, success: data.success, message })
2026-02-18 17:24:26 +01:00
} catch (err) {
setTestResult({ channel, success: false, message: String(err) })
} finally {
setTesting(null)
2026-02-19 20:30:11 +01:00
setTimeout(() => setTestResult(null), 8000)
2026-02-18 17:24:26 +01:00
}
}
const handleClearHistory = async () => {
try {
await fetchApi("/api/notifications/history", { method: "DELETE" })
setHistory([])
} catch {
// Ignore
}
}
const toggleSecret = (key: string) => {
setShowSecrets(prev => ({ ...prev, [key]: !prev[key] }))
}
if (loading) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-blue-500" />
<CardTitle>Notifications</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
</CardContent>
</Card>
)
}
const activeChannels = Object.entries(config.channels).filter(([, ch]) => ch.enabled).length
2026-02-19 17:02:02 +01:00
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">
2026-02-19 18:37:42 +01:00
Append to /etc/proxmox-backup/notifications.cfg on the PBS host:
2026-02-19 17:02:02 +01:00
</p>
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
2026-02-19 17:26:36 +01:00
{`webhook: proxmenux-webhook
\tmethod post
2026-02-19 18:37:42 +01:00
\turl http://<PVE_IP>:8008/api/notifications/webhook
2026-02-19 17:02:02 +01:00
2026-02-19 17:26:36 +01:00
matcher: proxmenux-pbs
\ttarget proxmenux-webhook
\tmatch-severity warning,error`}
</pre>
</div>
2026-02-19 17:02:02 +01:00
<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" />
2026-02-19 18:37:42 +01:00
<p className="text-[10px] text-blue-400/90 leading-relaxed">
{"Replace <PVE_IP> with the IP of this PVE node (not 127.0.0.1, unless PBS runs on the same host). Append at the end -- do not delete existing content."}
</p>
2026-02-19 17:02:02 +01:00
</div>
</div>
</details>
</div>
</CardContent>
</Card>
)
}
2026-02-18 17:24:26 +01:00
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-blue-500" />
<CardTitle>Notifications</CardTitle>
{config.enabled && (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
Cancel
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
)}
</div>
</div>
<CardDescription>
2026-02-19 17:02:02 +01:00
Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.
2026-02-18 17:24:26 +01:00
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* ── Service Status ── */}
{status && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
<div className={`h-2.5 w-2.5 rounded-full ${status.running ? "bg-green-500" : "bg-red-500"}`} />
<div className="flex-1 min-w-0">
<span className="text-xs font-medium">
{status.running ? "Service running" : "Service stopped"}
</span>
{status.total_sent_24h > 0 && (
<span className="text-xs text-muted-foreground ml-2">
{status.total_sent_24h} sent in last 24h
</span>
)}
</div>
{activeChannels > 0 && (
<Badge variant="outline" className="text-[10px]">
{activeChannels} channel{activeChannels > 1 ? "s" : ""}
</Badge>
)}
</div>
)}
{/* ── Enable/Disable ── */}
<div className="flex items-center justify-between py-2 px-1">
<div className="flex items-center gap-2">
{config.enabled ? (
<Bell className="h-4 w-4 text-blue-500" />
) : (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
<div>
<span className="text-sm font-medium">Enable Notifications</span>
<p className="text-[11px] text-muted-foreground">Activate the notification service</p>
</div>
</div>
<button
className={`relative w-10 h-5 rounded-full transition-colors ${
config.enabled ? "bg-blue-600" : "bg-muted-foreground/30"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && updateConfig(p => ({ ...p, enabled: !p.enabled }))}
disabled={!editMode}
role="switch"
aria-checked={config.enabled}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.enabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{config.enabled && (
<>
{/* ── Channel Configuration ── */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Channels</span>
</div>
2026-02-19 20:30:11 +01:00
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
2026-02-18 17:24:26 +01:00
<Tabs defaultValue="telegram" className="w-full">
2026-02-19 17:02:02 +01:00
<TabsList className="w-full grid grid-cols-4 h-8">
2026-02-18 17:24:26 +01:00
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
Telegram
</TabsTrigger>
<TabsTrigger value="gotify" className="text-xs data-[state=active]:text-green-500">
Gotify
</TabsTrigger>
<TabsTrigger value="discord" className="text-xs data-[state=active]:text-indigo-500">
Discord
</TabsTrigger>
2026-02-19 17:02:02 +01:00
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
Email
</TabsTrigger>
2026-02-18 17:24:26 +01:00
</TabsList>
{/* Telegram */}
<TabsContent value="telegram" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Enable Telegram</Label>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.telegram?.enabled ? "bg-blue-600" : "bg-muted-foreground/30"
2026-02-19 19:56:20 +01:00
} cursor-pointer`}
onClick={() => updateChannel("telegram", "enabled", !config.channels.telegram?.enabled)}
2026-02-18 17:24:26 +01:00
role="switch"
aria-checked={config.channels.telegram?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.telegram?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.telegram?.enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Bot Token</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["tg_token"] ? "text" : "password"}
className="h-7 text-xs font-mono"
2026-02-19 20:51:54 +01:00
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
2026-02-18 17:24:26 +01:00
value={config.channels.telegram?.bot_token || ""}
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
/>
<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("tg_token")}
>
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Chat ID</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="-1001234567890"
value={config.channels.telegram?.chat_id || ""}
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
/>
</div>
2026-02-19 19:56:20 +01:00
{/* Per-channel action bar */}
<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-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
2026-02-18 17:24:26 +01:00
<button
2026-02-19 19:56:20 +01:00
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"
2026-02-18 17:24:26 +01:00
onClick={() => handleTest("telegram")}
2026-02-19 19:56:20 +01:00
disabled={testing === "telegram" || !config.channels.telegram?.bot_token}
2026-02-18 17:24:26 +01:00
>
2026-02-19 19:56:20 +01:00
{testing === "telegram" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
2026-02-18 17:24:26 +01:00
</button>
2026-02-19 19:56:20 +01:00
</div>
2026-02-18 17:24:26 +01:00
</>
)}
</TabsContent>
{/* Gotify */}
<TabsContent value="gotify" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Enable Gotify</Label>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.gotify?.enabled ? "bg-green-600" : "bg-muted-foreground/30"
2026-02-19 19:56:20 +01:00
} cursor-pointer`}
onClick={() => updateChannel("gotify", "enabled", !config.channels.gotify?.enabled)}
2026-02-18 17:24:26 +01:00
role="switch"
aria-checked={config.channels.gotify?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.gotify?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.gotify?.enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Server URL</Label>
<Input
className="h-7 text-xs font-mono"
placeholder="https://gotify.example.com"
value={config.channels.gotify?.url || ""}
onChange={e => updateChannel("gotify", "url", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">App Token</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["gt_token"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="A_valid_gotify_token"
value={config.channels.gotify?.token || ""}
onChange={e => updateChannel("gotify", "token", e.target.value)}
/>
<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("gt_token")}
>
{showSecrets["gt_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
2026-02-19 19:56:20 +01:00
{/* Per-channel action bar */}
<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-green-600 hover:bg-green-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
2026-02-18 17:24:26 +01:00
<button
2026-02-19 19:56:20 +01:00
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"
2026-02-18 17:24:26 +01:00
onClick={() => handleTest("gotify")}
2026-02-19 19:56:20 +01:00
disabled={testing === "gotify" || !config.channels.gotify?.url}
2026-02-18 17:24:26 +01:00
>
2026-02-19 19:56:20 +01:00
{testing === "gotify" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
2026-02-18 17:24:26 +01:00
</button>
2026-02-19 19:56:20 +01:00
</div>
2026-02-18 17:24:26 +01:00
</>
)}
</TabsContent>
{/* Discord */}
<TabsContent value="discord" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Enable Discord</Label>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.discord?.enabled ? "bg-indigo-600" : "bg-muted-foreground/30"
2026-02-19 19:56:20 +01:00
} cursor-pointer`}
onClick={() => updateChannel("discord", "enabled", !config.channels.discord?.enabled)}
2026-02-18 17:24:26 +01:00
role="switch"
aria-checked={config.channels.discord?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.discord?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.discord?.enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Webhook URL</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["dc_hook"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="https://discord.com/api/webhooks/..."
value={config.channels.discord?.webhook_url || ""}
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
/>
<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("dc_hook")}
>
{showSecrets["dc_hook"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
2026-02-19 19:56:20 +01:00
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
2026-02-18 17:24:26 +01:00
<button
2026-02-19 19:56:20 +01:00
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={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
2026-02-18 17:24:26 +01:00
onClick={() => handleTest("discord")}
2026-02-19 19:56:20 +01:00
disabled={testing === "discord" || !config.channels.discord?.webhook_url}
2026-02-18 17:24:26 +01:00
>
2026-02-19 19:56:20 +01:00
{testing === "discord" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
2026-02-18 17:24:26 +01:00
</button>
2026-02-19 19:56:20 +01:00
</div>
2026-02-18 17:24:26 +01:00
</>
)}
</TabsContent>
2026-02-19 17:02:02 +01:00
{/* 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"
2026-02-19 19:56:20 +01:00
} cursor-pointer`}
onClick={() => updateChannel("email", "enabled", !config.channels.email?.enabled)}
2026-02-19 17:02:02 +01:00
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)}
/>
</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)}
/>
</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)}
>
2026-02-19 19:56:20 +01:00
<SelectTrigger className="h-7 text-xs">
2026-02-19 17:02:02 +01:00
<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)}
/>
</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)}
/>
<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)}
/>
</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)}
/>
</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)}
/>
</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>
2026-02-19 19:56:20 +01:00
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
2026-02-19 17:02:02 +01:00
<button
2026-02-19 19:56:20 +01:00
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={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
2026-02-19 17:02:02 +01:00
onClick={() => handleTest("email")}
2026-02-19 19:56:20 +01:00
disabled={testing === "email" || !config.channels.email?.to_addresses}
2026-02-19 17:02:02 +01:00
>
2026-02-19 19:56:20 +01:00
{testing === "email" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
2026-02-19 17:02:02 +01:00
</button>
2026-02-19 19:56:20 +01:00
</div>
2026-02-19 17:02:02 +01:00
</>
)}
</TabsContent>
2026-02-18 17:24:26 +01:00
</Tabs>
{/* Test Result */}
{testResult && (
2026-02-19 20:30:11 +01:00
<div className={`flex items-center gap-2 p-2.5 rounded-md text-xs mt-2 ${
2026-02-18 17:24:26 +01:00
testResult.success
? "bg-green-500/10 border border-green-500/20 text-green-400"
: "bg-red-500/10 border border-red-500/20 text-red-400"
}`}>
{testResult.success ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0" />
)}
<span>{testResult.message}</span>
</div>
)}
2026-02-19 20:30:11 +01:00
</div>{/* close bordered channel container */}
2026-02-18 17:24:26 +01:00
</div>
2026-02-19 20:30:11 +01:00
{/* ── Filters ── */}
<div className="space-y-3 border-t border-border pt-4">
2026-02-18 17:24:26 +01:00
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
2026-02-19 20:30:11 +01:00
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Filters & Events</span>
2026-02-18 17:24:26 +01:00
</div>
2026-02-19 20:30:11 +01:00
<div className="rounded-lg border border-border/50 bg-muted/20 p-3 space-y-4">
{/* Severity */}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Severity Filter</Label>
<Select
value={config.severity_filter}
onValueChange={v => updateConfig(p => ({ ...p, severity_filter: v }))}
disabled={!editMode}
>
<SelectTrigger className={`h-8 text-xs ${!editMode ? "opacity-60" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
2026-02-18 17:24:26 +01:00
</div>
2026-02-19 20:30:11 +01:00
{/* Event Categories */}
<div className="space-y-1.5 border-t border-border/30 pt-3">
<Label className="text-[11px] text-muted-foreground">Event Categories</Label>
2026-02-18 17:24:26 +01:00
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{EVENT_CATEGORIES.map(cat => (
<label
key={cat.key}
className={`flex items-start gap-2.5 p-2 rounded-md border border-border/50 transition-colors ${
editMode ? "hover:bg-muted/50 cursor-pointer" : "opacity-60"
}`}
>
<Checkbox
checked={config.event_categories[cat.key] ?? true}
onCheckedChange={checked => {
if (!editMode) return
updateConfig(p => ({
...p,
event_categories: { ...p.event_categories, [cat.key]: !!checked },
}))
}}
disabled={!editMode}
className="mt-0.5"
/>
<div className="min-w-0">
<span className="text-xs font-medium block">{cat.label}</span>
<span className="text-[10px] text-muted-foreground">{cat.desc}</span>
</div>
</label>
))}
</div>
2026-02-19 20:30:11 +01:00
</div>
</div>{/* close bordered filters container */}
2026-02-18 17:24:26 +01:00
</div>
2026-02-19 17:02:02 +01:00
{/* ── Proxmox Webhook ── */}
2026-02-19 20:30:11 +01:00
<div className="space-y-3 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-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>
<div className="rounded-lg border border-border/50 bg-muted/20 p-3 space-y-3">
2026-02-19 17:02:02 +01:00
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
2026-02-19 20:30:11 +01:00
<span className="text-[11px] font-medium">PVE Webhook Configuration</span>
2026-02-19 17:02:02 +01:00
</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">
2026-02-19 18:37:42 +01:00
{"Used for remote connections only (e.g. PBS on another host). Local PVE webhook runs on localhost and does not need this header."}
2026-02-19 17:02:02 +01:00
</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>
2026-02-19 20:30:11 +01:00
</div>{/* close bordered webhook container */}
2026-02-19 17:02:02 +01:00
{/* 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>
2026-02-19 17:26:36 +01:00
<p className="text-[10px] font-medium text-muted-foreground">
2026-02-19 18:37:42 +01:00
Append to /etc/proxmox-backup/notifications.cfg:
2026-02-19 17:26:36 +01:00
</p>
2026-02-19 17:02:02 +01:00
<pre className="text-[10px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
2026-02-19 17:26:36 +01:00
{`webhook: proxmenux-webhook
\tmethod post
2026-02-19 18:37:42 +01:00
\turl http://<PVE_IP>:8008/api/notifications/webhook
2026-02-19 17:02:02 +01:00
2026-02-19 17:26:36 +01:00
matcher: proxmenux-pbs
\ttarget proxmenux-webhook
\tmatch-severity warning,error`}
2026-02-19 17:02:02 +01:00
</pre>
<p className="text-[10px] text-muted-foreground">
2026-02-19 18:37:42 +01:00
{"Replace <PVE_IP> with this node's IP. Append at the end -- do not delete existing content."}
2026-02-19 17:02:02 +01:00
</p>
</div>
</details>
</div>
2026-02-18 17:24:26 +01:00
{/* ── Advanced: AI Enhancement ── */}
<div>
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full py-1"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
{config.ai_enabled && (
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
ON
</Badge>
)}
</button>
{showAdvanced && (
<div className="space-y-3 mt-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div>
<span className="text-xs font-medium">AI-Enhanced Messages</span>
<p className="text-[10px] text-muted-foreground">Use AI to generate contextual notification messages</p>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/30"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && updateConfig(p => ({ ...p, ai_enabled: !p.ai_enabled }))}
disabled={!editMode}
role="switch"
aria-checked={config.ai_enabled}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.ai_enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.ai_enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Provider</Label>
<Select
value={config.ai_provider}
onValueChange={v => updateConfig(p => ({ ...p, ai_provider: v }))}
disabled={!editMode}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">API Key</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-7 text-xs font-mono"
placeholder="sk-..."
value={config.ai_api_key}
onChange={e => updateConfig(p => ({ ...p, ai_api_key: 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("ai_key")}
>
{showSecrets["ai_key"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Model (optional)</Label>
<Input
className="h-7 text-xs font-mono"
placeholder={config.ai_provider === "openai" ? "gpt-4o-mini" : "llama-3.3-70b-versatile"}
value={config.ai_model}
onChange={e => updateConfig(p => ({ ...p, ai_model: e.target.value }))}
disabled={!editMode}
/>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-purple-400/90 leading-relaxed">
AI enhancement is optional. When enabled, notifications include contextual analysis and recommended actions. If the AI service is unavailable, standard templates are used as fallback.
</p>
</div>
</>
)}
</div>
)}
</div>
{/* ── Notification History ── */}
<div>
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full py-1"
onClick={() => setShowHistory(!showHistory)}
>
{showHistory ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="font-medium uppercase tracking-wider">Recent History</span>
{history.length > 0 && (
<Badge variant="outline" className="text-[9px] ml-1">{history.length}</Badge>
)}
</button>
{showHistory && (
<div className="mt-3 space-y-2">
{history.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No notifications sent yet</p>
) : (
<>
<div className="flex items-center justify-end">
<button
className="h-6 px-2 text-[10px] rounded-md text-muted-foreground hover:text-red-400 transition-colors flex items-center gap-1"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear
</button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{history.map(entry => (
<div
key={entry.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/30 border border-border/50"
>
{entry.success ? (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium truncate block">{entry.title || entry.event_type}</span>
<span className="text-[10px] text-muted-foreground">
{entry.channel} - {new Date(entry.sent_at).toLocaleString()}
</span>
</div>
<Badge
variant="outline"
className={`text-[9px] shrink-0 ${
entry.severity === "critical"
? "border-red-500/30 text-red-400"
: entry.severity === "warning"
? "border-amber-500/30 text-amber-400"
: "border-blue-500/30 text-blue-400"
}`}
>
{entry.severity}
</Badge>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
</>
)}
{/* ── Footer info ── */}
<div className="flex items-start gap-2 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<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."
2026-02-19 17:02:02 +01:00
: "Enable notifications to receive alerts about system events, health status changes, and security incidents via Telegram, Gotify, Discord, or Email."}
2026-02-18 17:24:26 +01:00
</p>
</div>
</CardContent>
</Card>
)
}