"use client" import { useState, useEffect, useCallback } from "react" import { useTheme } from "next-themes" 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 { Button } from "./ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" import { fetchApi } from "../lib/api-config" import { Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook, Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb } from "lucide-react" interface ChannelConfig { enabled: boolean rich_format?: boolean bot_token?: string chat_id?: string topic_id?: string // Telegram topic ID for supergroups with topics url?: string token?: string webhook_url?: string // Email channel fields host?: string port?: string username?: string password?: string tls_mode?: string from_address?: string to_addresses?: string subject_prefix?: string } interface EventTypeInfo { type: string title: string default_enabled: boolean } interface ChannelOverrides { categories: Record events: Record } interface NotificationConfig { enabled: boolean channels: Record event_categories: Record event_toggles: Record event_types_by_group: Record channel_overrides: Record ai_enabled: boolean ai_provider: string ai_api_keys: Record // Per-provider API keys ai_models: Record // Per-provider selected models ai_model: string // Current active model (for the selected provider) ai_language: string ai_ollama_url: string ai_openai_base_url: string ai_prompt_mode: string // 'default' or 'custom' ai_custom_prompt: string // User's custom prompt ai_allow_suggestions: string | boolean // Enable AI suggestions (experimental) channel_ai_detail: Record hostname: string webhook_secret: string webhook_allowed_ips: string pbs_host: string pve_host: string pbs_trusted_sources: string } interface ServiceStatus { enabled: boolean running: boolean channels: Record 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 EVENT_CATEGORIES = [ { 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, 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" }, { key: "services", label: "Services", desc: "System services, shutdown, reboot" }, { key: "health", label: "Health Monitor", desc: "Health checks, degradation, recovery" }, { key: "updates", label: "Updates", desc: "System and PVE updates" }, { key: "other", label: "Other", desc: "Uncategorized notifications" }, ] const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const const AI_PROVIDERS = [ { value: "groq", label: "Groq", description: "Very fast, generous free tier (30 req/min). Ideal to start.", keyUrl: "https://console.groq.com/keys", icon: "/icons/Groq Logo_White 25.svg", iconLight: "/icons/Groq Logo_Black 25.svg" }, { value: "openai", label: "OpenAI", description: "Industry standard. Very accurate and widely used.", keyUrl: "https://platform.openai.com/api-keys", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai.webp", iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai-light.webp" }, { value: "anthropic", label: "Anthropic (Claude)", description: "Excellent for writing and translation. Fast and economical.", keyUrl: "https://console.anthropic.com/settings/keys", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-light.webp", iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-dark.webp" }, { value: "gemini", label: "Google Gemini", description: "Free tier available, great quality/price ratio.", keyUrl: "https://aistudio.google.com/app/apikey", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp", iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp" }, { value: "ollama", label: "Ollama (Local)", description: "Uses models available on your Ollama server. 100% local, no costs, total privacy.", keyUrl: "", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama.webp", iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama-light.webp" }, { value: "openrouter", label: "OpenRouter", description: "Aggregator with access to 100+ models using a single API key. Maximum flexibility.", keyUrl: "https://openrouter.ai/keys", icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-light.webp", iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-dark.webp" }, ] const AI_LANGUAGES = [ { value: "en", label: "English" }, { value: "es", label: "Espanol" }, { value: "fr", label: "Francais" }, { value: "de", label: "Deutsch" }, { value: "pt", label: "Portugues" }, { value: "it", label: "Italiano" }, { value: "ru", label: "Russkiy" }, { value: "sv", label: "Svenska" }, { value: "no", label: "Norsk" }, { value: "ja", label: "Nihongo" }, { value: "zh", label: "Zhongwen" }, { value: "nl", label: "Nederlands" }, ] const AI_DETAIL_LEVELS = [ { value: "brief", label: "Brief", desc: "2-3 lines, essential only" }, { value: "standard", label: "Standard", desc: "Concise with basic context" }, { value: "detailed", label: "Detailed", desc: "Complete technical details" }, ] // Example custom prompt for users to adapt const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor. Your task is to translate and format server notifications. RULES: 1. Translate to the user's preferred language 2. Use plain text only (no markdown, no bold, no italic) 3. Be concise and factual 4. Do not add recommendations or suggestions 5. Present only the facts from the input 6. Keep hostname prefix in titles (e.g., "pve01: ") OUTPUT FORMAT: [TITLE] your translated title here [BODY] your translated message here Detail levels: - brief: 2-3 lines, essential only - standard: short paragraph with key details - detailed: full technical breakdown` const DEFAULT_CONFIG: NotificationConfig = { enabled: false, channels: { telegram: { enabled: false }, gotify: { enabled: false }, discord: { enabled: false }, email: { enabled: false }, }, event_categories: { vm_ct: true, backup: true, resources: true, storage: true, network: true, security: true, cluster: true, services: true, health: true, updates: true, other: true, }, event_toggles: {}, event_types_by_group: {}, channel_overrides: { telegram: { categories: {}, events: {} }, gotify: { categories: {}, events: {} }, discord: { categories: {}, events: {} }, email: { categories: {}, events: {} }, }, ai_enabled: false, ai_provider: "groq", ai_api_keys: { groq: "", gemini: "", anthropic: "", openai: "", openrouter: "", }, ai_models: { groq: "", ollama: "", gemini: "", anthropic: "", openai: "", openrouter: "", }, ai_model: "", ai_language: "en", ai_ollama_url: "http://localhost:11434", ai_openai_base_url: "", ai_prompt_mode: "default", ai_custom_prompt: "", ai_allow_suggestions: "false", channel_ai_detail: { telegram: "brief", gotify: "brief", discord: "brief", email: "detailed", }, hostname: "", webhook_secret: "", webhook_allowed_ips: "", pbs_host: "", pve_host: "", pbs_trusted_sources: "", } export function NotificationSettings() { const { resolvedTheme } = useTheme() const [config, setConfig] = useState(DEFAULT_CONFIG) const [status, setStatus] = useState(null) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [testing, setTesting] = useState(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>({}) const [editMode, setEditMode] = useState(false) const [hasChanges, setHasChanges] = useState(false) const [expandedCategories, setExpandedCategories] = useState>(new Set()) const [originalConfig, setOriginalConfig] = useState(DEFAULT_CONFIG) const [showProviderInfo, setShowProviderInfo] = useState(false) const [showTelegramHelp, setShowTelegramHelp] = useState(false) const [testingAI, setTestingAI] = useState(false) const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null) const [providerModels, setProviderModels] = useState([]) const [loadingProviderModels, setLoadingProviderModels] = useState(false) const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false) const [editingCustomPrompt, setEditingCustomPrompt] = useState(false) const [customPromptDraft, setCustomPromptDraft] = useState("") const [webhookSetup, setWebhookSetup] = useState<{ status: "idle" | "running" | "success" | "failed" fallback_commands: string[] error: string }>({ status: "idle", fallback_commands: [], error: "" }) const [systemHostname, setSystemHostname] = useState("") // Load system hostname for display name placeholder const loadSystemHostname = useCallback(async () => { try { const data = await fetchApi<{ hostname?: string }>("/api/system") if (data.hostname) { setSystemHostname(data.hostname) } } catch { // Ignore - will show generic placeholder } }, []) const loadConfig = useCallback(async () => { try { const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings") if (data.success && data.config) { // Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs) const configWithDefaults = { ...data.config, ai_api_keys: data.config.ai_api_keys || { groq: "", ollama: "", gemini: "", anthropic: "", openai: "", openrouter: "", }, ai_models: data.config.ai_models || { groq: "", ollama: "", gemini: "", anthropic: "", openai: "", openrouter: "", }, ai_prompt_mode: data.config.ai_prompt_mode || "default", ai_custom_prompt: data.config.ai_custom_prompt || "", ai_allow_suggestions: data.config.ai_allow_suggestions || "false", } // If ai_model exists but ai_models doesn't have it, save it if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) { configWithDefaults.ai_models[configWithDefaults.ai_provider] = configWithDefaults.ai_model } setConfig(configWithDefaults) setOriginalConfig(configWithDefaults) } } 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() loadSystemHostname() }, [loadConfig, loadStatus, loadSystemHostname]) useEffect(() => { if (showHistory) loadHistory() }, [showHistory, loadHistory]) // Auto-expand AI section when AI is enabled useEffect(() => { if (config.ai_enabled) { setShowAdvanced(true) } }, [config.ai_enabled]) 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 }, }, })) } /** Reusable 10+1 category block rendered inside each channel tab. */ const renderChannelCategories = (chName: string) => { const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} } const evtByGroup = config.event_types_by_group || {} return (
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { const isEnabled = overrides.categories[cat.key] ?? true const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) const eventsForGroup = evtByGroup[cat.key] || [] const enabledCount = eventsForGroup.filter( e => (overrides.events?.[e.type] ?? e.default_enabled) ).length return (
{/* Category row -- entire block is clickable to expand/collapse */}
{ if (!isEnabled) return setExpandedCategories(prev => { const next = new Set(prev) const key = `${chName}.${cat.key}` if (next.has(key)) next.delete(key) else next.add(key) return next }) }} > {/* Expand arrow */} {/* Label */}
{cat.label}
{/* Count badge */} {isEnabled && eventsForGroup.length > 0 && ( {enabledCount}/{eventsForGroup.length} )} {/* Toggle -- same style as channel enable toggle */}
{/* Sub-event toggles */} {isEnabled && isExpanded && eventsForGroup.length > 0 && (
{eventsForGroup.map(evt => { const evtEnabled = overrides.events?.[evt.type] ?? evt.default_enabled return (
{evt.title}
) })}
)}
) })}
) } /** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */ const flattenConfig = (cfg: NotificationConfig): Record => { const flat: Record = { enabled: String(cfg.enabled), ai_enabled: String(cfg.ai_enabled), ai_provider: cfg.ai_provider, ai_model: cfg.ai_model, ai_language: cfg.ai_language, ai_ollama_url: cfg.ai_ollama_url, ai_openai_base_url: cfg.ai_openai_base_url, ai_prompt_mode: cfg.ai_prompt_mode || "default", ai_custom_prompt: cfg.ai_custom_prompt || "", ai_allow_suggestions: String(cfg.ai_allow_suggestions === "true" || cfg.ai_allow_suggestions === true), 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 per-provider API keys if (cfg.ai_api_keys) { for (const [provider, key] of Object.entries(cfg.ai_api_keys)) { if (key) { flat[`ai_api_key_${provider}`] = key } } } // Flatten per-provider selected models if (cfg.ai_models) { for (const [provider, model] of Object.entries(cfg.ai_models)) { if (model) { flat[`ai_model_${provider}`] = model } } } // 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 ?? "") } } // Per-channel category & event toggles: telegram.events.vm_ct, telegram.event.vm_start, etc. // Each channel independently owns its notification preferences. if (cfg.channel_overrides) { for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) { if (overrides.categories) { for (const [cat, enabled] of Object.entries(overrides.categories)) { flat[`${chName}.events.${cat}`] = String(enabled) } } if (overrides.events) { for (const [evt, enabled] of Object.entries(overrides.events)) { flat[`${chName}.event.${evt}`] = String(enabled) } } } } // Per-channel AI detail level if (cfg.channel_ai_detail) { for (const [chName, level] of Object.entries(cfg.channel_ai_detail)) { flat[`${chName}.ai_detail_level`] = level } } return flat } const handleSave = async () => { setSaving(true) try { // If notifications are being disabled, clean up PVE webhook first const wasEnabled = originalConfig.enabled const isNowDisabled = !config.enabled if (wasEnabled && isNowDisabled) { try { await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" }) } catch { // Non-fatal: webhook cleanup failed but we still save settings } } const payload = flattenConfig(config) await fetchApi("/api/notifications/settings", { method: "POST", body: JSON.stringify(payload), }) 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 { // 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) const data = await fetchApi<{ success: boolean message?: string error?: string results?: Record }>("/api/notifications/test", { method: "POST", body: JSON.stringify({ channel }), }) // 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 }) } catch (err) { setTestResult({ channel, success: false, message: String(err) }) } finally { setTesting(null) setTimeout(() => setTestResult(null), 8000) } } const fetchProviderModels = useCallback(async () => { const provider = config.ai_provider const apiKey = config.ai_api_keys?.[provider] || "" // For Ollama, we need the URL; for others, we need the API key if (provider === 'ollama') { if (!config.ai_ollama_url) return } else if (provider !== 'anthropic') { // Anthropic doesn't have a models list endpoint, skip validation if (!apiKey) return } setLoadingProviderModels(true) try { const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/api/notifications/provider-models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, api_key: apiKey, ollama_url: config.ai_ollama_url, openai_base_url: config.ai_openai_base_url, }), }) if (data.success && data.models && data.models.length > 0) { setProviderModels(data.models) // Auto-select recommended model if current selection is empty or not in the list updateConfig(prev => { if (!prev.ai_model || !data.models.includes(prev.ai_model)) { const modelToSelect = data.recommended || data.models[0] return { ...prev, ai_model: modelToSelect, ai_models: { ...prev.ai_models, [provider]: modelToSelect } } } return prev }) } else { setProviderModels([]) } } catch { setProviderModels([]) } finally { setLoadingProviderModels(false) } }, [config.ai_provider, config.ai_api_keys, config.ai_ollama_url, config.ai_openai_base_url]) // Note: Users use the "Load" button explicitly to fetch models. const handleTestAI = async () => { setTestingAI(true) setAiTestResult(null) try { // Get the API key for the current provider const currentApiKey = config.ai_api_keys?.[config.ai_provider] || "" // Use the model selected by the user (loaded from provider) const modelToUse = config.ai_model if (!modelToUse) { setAiTestResult({ success: false, message: "No model selected. Click 'Load' to fetch available models first." }) return } const data = await fetchApi<{ success: boolean; message: string; model: string }>("/api/notifications/test-ai", { method: "POST", body: JSON.stringify({ provider: config.ai_provider, api_key: currentApiKey, model: modelToUse, ollama_url: config.ai_ollama_url, openai_base_url: config.ai_openai_base_url, }), }) setAiTestResult(data) } catch (err) { setAiTestResult({ success: false, message: String(err) }) } finally { setTestingAI(false) setTimeout(() => setAiTestResult(null), 8000) } } 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 (
Notifications
) } const activeChannels = Object.entries(config.channels).filter(([, ch]) => ch.enabled).length const handleEnable = async () => { setSaving(true) setWebhookSetup({ status: "running", fallback_commands: [], error: "" }) try { // 1) Save enabled=true const newConfig = { ...config, enabled: true } await fetchApi("/api/notifications/settings", { method: "POST", body: JSON.stringify(newConfig), }) setConfig(newConfig) setOriginalConfig(newConfig) // 2) Auto-configure PVE webhook try { const setup = await fetchApi<{ configured: boolean secret?: string fallback_commands?: string[] error?: string }>("/api/notifications/proxmox/setup-webhook", { method: "POST" }) if (setup.configured) { setWebhookSetup({ status: "success", fallback_commands: [], error: "" }) // Update secret in local config if one was generated if (setup.secret) { const updated = { ...newConfig, webhook_secret: setup.secret } setConfig(updated) setOriginalConfig(updated) } } else { setWebhookSetup({ status: "failed", fallback_commands: setup.fallback_commands || [], error: setup.error || "Unknown error", }) } } catch { setWebhookSetup({ status: "failed", fallback_commands: [], error: "Could not reach setup endpoint", }) } setEditMode(true) loadStatus() } catch (err) { console.error("Failed to enable notifications:", err) setWebhookSetup({ status: "idle", fallback_commands: [], error: "" }) } finally { setSaving(false) } } // ── Disabled state: show activation card ── if (!config.enabled && !editMode) { return (
Notifications Disabled
Get real-time alerts about your Proxmox environment via Telegram, Discord, Gotify, or Email.

Enable notification service

Monitor system health, VM/CT events, backups, security alerts, and cluster status. PVE webhook integration is configured automatically.

{/* Webhook setup result */} {webhookSetup.status === "success" && (

PVE webhook configured automatically. Proxmox will send notifications to ProxMenux.

)} {webhookSetup.status === "failed" && (

Automatic PVE configuration failed: {webhookSetup.error}

Notifications are enabled. Run the commands below on the PVE host to complete webhook setup.

{webhookSetup.fallback_commands.length > 0 && (
{webhookSetup.fallback_commands.join('\n')}
                    
)}
)}
) } return ( <>
Notifications {config.enabled && ( Active )}
{saved && ( Saved )} {editMode ? ( <> ) : ( )}
Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.
{/* ── Service Status ── */} {status && (
{status.running ? "Service running" : "Service stopped"} {status.total_sent_24h > 0 && ( {status.total_sent_24h} sent in last 24h )}
{activeChannels > 0 && ( {activeChannels} channel{activeChannels > 1 ? "s" : ""} )}
)} {/* ── Enable/Disable ── */}
{config.enabled ? ( ) : ( )}
Enable Notifications

Activate the notification service

{config.enabled && ( <> {/* ── Channel Configuration ── */}
Channels
Telegram Gotify Discord Email {/* Telegram */}
{config.channels.telegram?.enabled && ( <>
updateChannel("telegram", "bot_token", e.target.value)} disabled={!editMode} />
updateChannel("telegram", "chat_id", e.target.value)} disabled={!editMode} />
updateChannel("telegram", "topic_id", e.target.value)} disabled={!editMode} />

For supergroups with topics enabled. Leave empty for regular chats.

{/* Message format */}

Enrich notifications with contextual emojis and icons

{renderChannelCategories("telegram")} {/* Send Test */}
)}
{/* Gotify */}
{config.channels.gotify?.enabled && ( <>
updateChannel("gotify", "url", e.target.value)} disabled={!editMode} />
updateChannel("gotify", "token", e.target.value)} disabled={!editMode} />
{/* Message format */}

Enrich notifications with contextual emojis and icons

{renderChannelCategories("gotify")} {/* Send Test */}
)}
{/* Discord */}
{config.channels.discord?.enabled && ( <>
updateChannel("discord", "webhook_url", e.target.value)} disabled={!editMode} />
{/* Message format */}

Enrich notifications with contextual emojis and icons

{renderChannelCategories("discord")} {/* Send Test */}
)}
{/* Email */}
{config.channels.email?.enabled && ( <>
updateChannel("email", "host", e.target.value)} disabled={!editMode} />
updateChannel("email", "port", e.target.value)} disabled={!editMode} />
updateChannel("email", "username", e.target.value)} disabled={!editMode} />
updateChannel("email", "password", e.target.value)} disabled={!editMode} />
updateChannel("email", "from_address", e.target.value)} disabled={!editMode} />
updateChannel("email", "to_addresses", e.target.value)} disabled={!editMode} />
updateChannel("email", "subject_prefix", e.target.value)} disabled={!editMode} />

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.

{renderChannelCategories("email")} {/* Send Test */}
)}
{/* Test Result */} {testResult && (
{testResult.success ? ( ) : ( )} {testResult.message}
)}
{/* close bordered channel container */}
{/* ── Display Name ── */}
updateConfig(p => ({ ...p, hostname: e.target.value }))} disabled={!editMode} readOnly={!editMode} />

Name shown in notifications. Edit to customize, or leave empty to use the system hostname.

{/* ── Advanced: AI Enhancement ── */}
{showAdvanced && (
{editMode ? ( <> ) : ( )}
)}
{showAdvanced && (
AI-Enhanced Messages

Use AI to generate contextual notification messages

{config.ai_enabled && ( <> {/* Provider + Info button */}
{/* Ollama URL (conditional) */} {config.ai_provider === "ollama" && (
updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))} disabled={!editMode} />
)} {/* Custom Base URL for OpenAI-compatible APIs */} {config.ai_provider === "openai" && (
(optional)
updateConfig(p => ({ ...p, ai_openai_base_url: e.target.value }))} disabled={!editMode} />

For OpenAI-compatible APIs: BytePlus, LocalAI, LM Studio, vLLM, etc.

)} {/* API Key (not shown for Ollama) */} {config.ai_provider !== "ollama" && (
updateConfig(p => ({ ...p, ai_api_keys: { ...p.ai_api_keys, [p.ai_provider]: e.target.value } }))} disabled={!editMode} />
)} {/* Model - selector with Load button for all providers */}
{providerModels.length > 0 && (

{providerModels.length} models available

)}
{/* Prompt Mode section */}
{/* Default mode options: Language and Detail Level per Channel */} {(config.ai_prompt_mode || "default") === "default" && (
{/* Language selector - only for default mode */}
{/* Detail Level per Channel */}
{CHANNEL_TYPES.map(ch => (
{ch}
))}

AI translates and formats notifications to your selected language. Each channel can have different detail levels.

{/* Experimental: AI Suggestions toggle */}
AI Suggestions BETA

Allow AI to add brief troubleshooting tips based on log context

)} {/* Custom mode: Editable prompt textarea */} {config.ai_prompt_mode === "custom" && (
{!editingCustomPrompt ? ( ) : ( <> )}