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 24 h
< / 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 >
)
}