Files
ProxMenux/AppImage/components/notification-settings.tsx
2026-03-28 17:27:29 +01:00

2292 lines
110 KiB
TypeScript

"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<string, boolean>
events: Record<string, boolean>
}
interface NotificationConfig {
enabled: boolean
channels: Record<string, ChannelConfig>
event_categories: Record<string, boolean>
event_toggles: Record<string, boolean>
event_types_by_group: Record<string, EventTypeInfo[]>
channel_overrides: Record<string, ChannelOverrides>
ai_enabled: boolean
ai_provider: string
ai_api_keys: Record<string, string> // Per-provider API keys
ai_models: Record<string, string> // 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<string, string>
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<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 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<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 [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [originalConfig, setOriginalConfig] = useState<NotificationConfig>(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<string[]>([])
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 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()
}, [loadConfig, loadStatus])
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 (
<div className="space-y-1.5 border-t border-border/30 pt-3 mt-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
<Label className="text-[11px] text-muted-foreground">Notification Categories</Label>
</div>
<div className="space-y-2">
{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 (
<div key={cat.key} className="rounded-lg border border-border transition-all duration-150 hover:border-muted-foreground/60 hover:bg-muted">
{/* Category row -- entire block is clickable to expand/collapse */}
<div
className="flex items-center gap-2.5 py-2.5 px-3 cursor-pointer"
onClick={() => {
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 */}
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${
isExpanded ? "rotate-90" : ""
} ${!isEnabled ? "opacity-20" : "text-muted-foreground"}`} />
{/* Label */}
<div className="flex-1 min-w-0">
<span className={`text-xs sm:text-sm font-medium block ${
isEnabled ? "text-green-500" : "text-foreground"
}`}>{cat.label}</span>
</div>
{/* Count badge */}
{isEnabled && eventsForGroup.length > 0 && (
<span className="text-[10px] text-muted-foreground tabular-nums">
{enabledCount}/{eventsForGroup.length}
</span>
)}
{/* Toggle -- same style as channel enable toggle */}
<button
type="button"
role="switch"
aria-checked={isEnabled}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${isEnabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={(e) => {
e.stopPropagation()
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
const newEnabled = !isEnabled
const newEvents = { ...(ch.events || {}) }
if (newEnabled && eventsForGroup.length > 0) {
for (const evt of eventsForGroup) {
newEvents[evt.type] = true
}
}
return {
...p,
channel_overrides: {
...p.channel_overrides,
[chName]: { categories: { ...ch.categories, [cat.key]: newEnabled }, events: newEvents },
},
}
})
}}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
isEnabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{/* Sub-event toggles */}
{isEnabled && isExpanded && eventsForGroup.length > 0 && (
<div className="border-t border-border px-3 py-1.5 space-y-0.5">
{eventsForGroup.map(evt => {
const evtEnabled = overrides.events?.[evt.type] ?? evt.default_enabled
return (
<div key={evt.type} className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-muted transition-colors">
<span className={`text-[11px] sm:text-xs ${evtEnabled ? "text-foreground" : "text-muted-foreground"}`}>
{evt.title}
</span>
<button
type="button"
role="switch"
aria-checked={evtEnabled}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${evtEnabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => {
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
return {
...p,
channel_overrides: {
...p.channel_overrides,
[chName]: { ...ch, events: { ...(ch.events || {}), [evt.type]: !evtEnabled } },
},
}
})
}}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
evtEnabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
/** 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),
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<string, { success: boolean; error?: string | null }>
}>("/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 (
<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
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>
</div>
</CardContent>
</Card>
)
}
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>
Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.
</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/20 border border-muted-foreground/40"
} ${!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>
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
<Tabs defaultValue="telegram" className="w-full">
<TabsList className="w-full grid grid-cols-4 h-8">
<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>
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
Email
</TabsTrigger>
</TabsList>
{/* Telegram */}
<TabsContent value="telegram" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Enable Telegram</Label>
<button
onClick={() => setShowTelegramHelp(true)}
className="text-[10px] text-blue-500 hover:text-blue-400 hover:underline"
>
+setup guide
</button>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.telegram?.enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => { if (editMode) updateChannel("telegram", "enabled", !config.channels.telegram?.enabled) }}
disabled={!editMode}
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 ${!editMode ? "opacity-50" : ""}`}
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
value={config.channels.telegram?.bot_token || ""}
onChange={e => updateChannel("telegram", "bot_token", 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("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 ${!editMode ? "opacity-50" : ""}`}
placeholder="-1001234567890"
value={config.channels.telegram?.chat_id || ""}
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Topic ID <span className="text-muted-foreground/60">(optional)</span></Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="123456"
value={config.channels.telegram?.topic_id || ""}
onChange={e => updateChannel("telegram", "topic_id", e.target.value)}
disabled={!editMode}
/>
<p className="text-[10px] text-muted-foreground">For supergroups with topics enabled. Leave empty for regular chats.</p>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.telegram?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.telegram?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("telegram", "rich_format", !config.channels.telegram?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.telegram?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("telegram")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("telegram")}
disabled={testing === "telegram" || !config.channels.telegram?.bot_token}
>
{testing === "telegram" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
</button>
</div>
</>
)}
</TabsContent>
{/* 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-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => { if (editMode) updateChannel("gotify", "enabled", !config.channels.gotify?.enabled) }}
disabled={!editMode}
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 ${!editMode ? "opacity-50" : ""}`}
placeholder="https://gotify.example.com"
value={config.channels.gotify?.url || ""}
onChange={e => updateChannel("gotify", "url", e.target.value)}
disabled={!editMode}
/>
</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 ${!editMode ? "opacity-50" : ""}`}
placeholder="A_valid_gotify_token"
value={config.channels.gotify?.token || ""}
onChange={e => updateChannel("gotify", "token", 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("gt_token")}
>
{showSecrets["gt_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.gotify?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.gotify?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("gotify", "rich_format", !config.channels.gotify?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.gotify?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("gotify")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("gotify")}
disabled={testing === "gotify" || !config.channels.gotify?.url}
>
{testing === "gotify" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
</button>
</div>
</>
)}
</TabsContent>
{/* 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-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => { if (editMode) updateChannel("discord", "enabled", !config.channels.discord?.enabled) }}
disabled={!editMode}
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 ${!editMode ? "opacity-50" : ""}`}
placeholder="https://discord.com/api/webhooks/..."
value={config.channels.discord?.webhook_url || ""}
onChange={e => updateChannel("discord", "webhook_url", 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("dc_hook")}
>
{showSecrets["dc_hook"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.discord?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.discord?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("discord", "rich_format", !config.channels.discord?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.discord?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("discord")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("discord")}
disabled={testing === "discord" || !config.channels.discord?.webhook_url}
>
{testing === "discord" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
</button>
</div>
</>
)}
</TabsContent>
{/* 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-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => { if (editMode) updateChannel("email", "enabled", !config.channels.email?.enabled) }}
disabled={!editMode}
role="switch"
aria-checked={config.channels.email?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.email?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.email?.enabled && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">SMTP Host</Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="smtp.gmail.com"
value={config.channels.email?.host || ""}
onChange={e => updateChannel("email", "host", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Port</Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="587"
value={config.channels.email?.port || ""}
onChange={e => updateChannel("email", "port", e.target.value)}
disabled={!editMode}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">TLS Mode</Label>
<Select
value={config.channels.email?.tls_mode || "starttls"}
onValueChange={v => updateChannel("email", "tls_mode", v)}
disabled={!editMode}
>
<SelectTrigger className={`h-7 text-xs ${!editMode ? "opacity-50" : ""}`}>
<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 ${!editMode ? "opacity-50" : ""}`}
placeholder="user@example.com"
value={config.channels.email?.username || ""}
onChange={e => updateChannel("email", "username", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Password</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["em_pass"] ? "text" : "password"}
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="App password"
value={config.channels.email?.password || ""}
onChange={e => updateChannel("email", "password", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
onClick={() => toggleSecret("em_pass")}
>
{showSecrets["em_pass"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">From Address</Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="proxmenux@yourdomain.com"
value={config.channels.email?.from_address || ""}
onChange={e => updateChannel("email", "from_address", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">To Addresses (comma-separated)</Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="admin@example.com, ops@example.com"
value={config.channels.email?.to_addresses || ""}
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Subject Prefix</Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="[ProxMenux]"
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-amber-400/90 leading-relaxed">
Leave SMTP Host empty to use local sendmail (must be installed on the server).
For Gmail, use an App Password instead of your account password.
</p>
</div>
{renderChannelCategories("email")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("email")}
disabled={testing === "email" || !config.channels.email?.to_addresses}
>
{testing === "email" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
</button>
</div>
</>
)}
</TabsContent>
</Tabs>
{/* Test Result */}
{testResult && (
<div className={`flex items-center gap-2 p-2.5 rounded-md text-xs mt-2 ${
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>
)}
</div>{/* close bordered channel container */}
</div>
{/* ── Advanced: AI Enhancement ── */}
<div>
<div className="flex items-center justify-between py-1">
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
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="flex items-center gap-2">
{editMode ? (
<>
<button
className="h-6 px-2 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-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
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-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
)}
</div>
)}
</div>
{showAdvanced && (
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">AI-Enhanced Messages</span>
<p className="text-xs sm:text-sm text-muted-foreground">Use AI to generate contextual notification messages</p>
</div>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!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 && (
<>
{/* Provider + Info button */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-purple-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Provider</Label>
<button
onClick={() => setShowProviderInfo(true)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
+info
</button>
</div>
<Select
value={config.ai_provider}
onValueChange={v => {
// Save current model for current provider before switching
const currentProvider = config.ai_provider
const currentModel = config.ai_model
// Restore previously saved model for the new provider (if any)
const savedModel = config.ai_models?.[v] || ''
updateConfig(p => ({
...p,
ai_provider: v,
ai_model: savedModel,
ai_models: {
...p.ai_models,
[currentProvider]: currentModel // Save old provider's model
}
}))
setProviderModels([]) // Clear loaded models list
}}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Ollama URL (conditional) */}
{config.ai_provider === "ollama" && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label>
<Input
className="h-9 text-sm font-mono"
placeholder="http://localhost:11434"
value={config.ai_ollama_url}
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
disabled={!editMode}
/>
</div>
)}
{/* Custom Base URL for OpenAI-compatible APIs */}
{config.ai_provider === "openai" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-xs sm:text-sm text-foreground/80">Custom Base URL</Label>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
<Input
className="h-9 text-sm font-mono"
placeholder="Leave empty for OpenAI, or enter custom endpoint"
value={config.ai_openai_base_url}
onChange={e => updateConfig(p => ({ ...p, ai_openai_base_url: e.target.value }))}
disabled={!editMode}
/>
<p className="text-xs text-muted-foreground">
For OpenAI-compatible APIs: BytePlus, LocalAI, LM Studio, vLLM, etc.
</p>
</div>
)}
{/* API Key (not shown for Ollama) */}
{config.ai_provider !== "ollama" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-xs sm:text-sm text-foreground/80">API Key</Label>
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
<a
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
>
Get key <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="flex items-center gap-2">
<Input
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-9 text-sm font-mono"
placeholder="sk-..."
value={config.ai_api_keys?.[config.ai_provider] || ""}
onChange={e => updateConfig(p => ({
...p,
ai_api_keys: {
...p.ai_api_keys,
[p.ai_provider]: e.target.value
}
}))}
disabled={!editMode}
/>
<button
className="h-9 w-9 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-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
)}
{/* Model - selector with Load button for all providers */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Brain className="h-4 w-4 text-blue-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
</div>
<div className="flex items-center gap-2">
<Select
value={config.ai_model || ""}
onValueChange={v => updateConfig(p => ({
...p,
ai_model: v,
ai_models: { ...p.ai_models, [p.ai_provider]: v } // Also save per-provider
}))}
disabled={!editMode || loadingProviderModels || providerModels.length === 0}
>
<SelectTrigger className="h-9 text-sm font-mono flex-1">
<SelectValue placeholder={providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
{config.ai_model || (providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{providerModels.length > 0 ? (
providerModels.map(m => (
<SelectItem key={m} value={m} className="font-mono">{m}</SelectItem>
))
) : (
<SelectItem value="_none" disabled className="text-muted-foreground">
No models loaded - click Load button
</SelectItem>
)}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-9 px-3 shrink-0"
onClick={() => fetchProviderModels()}
disabled={
loadingProviderModels ||
(config.ai_provider === 'ollama' && !config.ai_ollama_url) ||
(config.ai_provider !== 'ollama' && config.ai_provider !== 'anthropic' && !config.ai_api_keys?.[config.ai_provider])
}
>
{loadingProviderModels ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Load
</>
)}
</Button>
</div>
{providerModels.length > 0 && (
<p className="text-xs text-green-500">{providerModels.length} models available</p>
)}
</div>
{/* Prompt Mode section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-amber-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Prompt Mode</Label>
</div>
<Select
value={config.ai_prompt_mode || "default"}
onValueChange={v => {
updateConfig(p => ({ ...p, ai_prompt_mode: v }))
// Show info modal when switching to custom for the first time
if (v === "custom" && !config.ai_custom_prompt) {
setShowCustomPromptInfo(true)
}
}}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Prompt</SelectItem>
<SelectItem value="custom">Custom Prompt</SelectItem>
</SelectContent>
</Select>
</div>
{/* Default mode options: Language and Detail Level per Channel */}
{(config.ai_prompt_mode || "default") === "default" && (
<div className="space-y-3 pt-3 border-t border-border/50">
{/* Language selector - only for default mode */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-green-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
</div>
<Select
value={config.ai_language || "en"}
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select language">
{AI_LANGUAGES.find(l => l.value === (config.ai_language || "en"))?.label || "English"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{AI_LANGUAGES.map(l => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Detail Level per Channel */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
<div className="grid grid-cols-2 gap-3">
{CHANNEL_TYPES.map(ch => (
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
<Select
value={config.channel_ai_detail?.[ch] || "standard"}
onValueChange={v => updateConfig(p => ({
...p,
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
}))}
disabled={!editMode}
>
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_DETAIL_LEVELS.map(l => (
<SelectItem key={l.value} value={l.value} className="text-xs">
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
AI translates and formats notifications to your selected language. Each channel can have different detail levels.
</p>
</div>
</div>
{/* Experimental: AI Suggestions toggle */}
<div className="flex items-center justify-between pt-3 border-t border-border/50">
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">AI Suggestions</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium">BETA</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
Allow AI to add brief troubleshooting tips based on log context
</p>
</div>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true
? "bg-purple-600"
: "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => {
if (editMode) {
const newValue = config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "false" : "true"
updateConfig(p => ({ ...p, ai_allow_suggestions: newValue }))
}
}}
disabled={!editMode}
role="switch"
aria-checked={config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
</div>
)}
{/* Custom mode: Editable prompt textarea */}
{config.ai_prompt_mode === "custom" && (
<div className="space-y-3 pt-3 border-t border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm text-foreground/80">Custom Prompt</Label>
<div className="flex gap-1">
{!editingCustomPrompt ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setCustomPromptDraft(config.ai_custom_prompt || "")
setEditingCustomPrompt(true)
}}
className="h-7 px-2 text-xs flex items-center gap-1"
>
<Pencil className="h-3 w-3" />
Edit
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomPrompt(false)
setCustomPromptDraft("")
}}
className="h-7 px-2 text-xs"
>
Cancel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
setEditingCustomPrompt(false)
handleSave()
}}
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
>
<Save className="h-3 w-3" />
Save
</Button>
</>
)}
</div>
</div>
<textarea
value={editingCustomPrompt ? customPromptDraft : (config.ai_custom_prompt || "")}
onChange={e => setCustomPromptDraft(e.target.value)}
disabled={!editingCustomPrompt}
placeholder="Enter your custom prompt instructions for the AI..."
className="w-full h-48 px-3 py-2 text-sm rounded-md border border-border bg-background resize-y focus:outline-none focus:ring-2 focus:ring-purple-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={editingCustomPrompt}
onClick={() => {
const blob = new Blob([config.ai_custom_prompt || ""], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "proxmenux_custom_prompt.txt"
a.click()
URL.revokeObjectURL(url)
}}
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
disabled={editingCustomPrompt}
onClick={() => {
const input = document.createElement("input")
input.type = "file"
input.accept = ".txt,.md"
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const text = await file.text()
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
handleSave()
}
}
input.click()
}}
className="flex items-center gap-1"
>
<Upload className="h-4 w-4" />
Import
</Button>
<a
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
Community prompts <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
Define your own prompt rules and format. You control the detail level and style of all notifications. Export to share with others or import prompts from the community.
</p>
</div>
</div>
)}
{/* Test Connection button - moved to end */}
<div className="space-y-3 pt-3 border-t border-border/50">
<button
onClick={handleTestAI}
disabled={
!editMode ||
testingAI ||
!config.ai_model ||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
}
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
) : (
<><Zap className="h-4 w-4" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-3 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-xs sm:text-sm leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
</div>
)}
</div>
</>
)}
</div>
)}
</div>
</>
)}
{/* ── Footer info ── */}
<div className="flex items-start gap-2 pt-3 border-t border-border">
<Info className="h-4 w-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
{config.enabled
? "Notifications are active. Each channel sends events based on its own category and event selection."
: "Enable notifications to receive alerts about system events, health status changes, and security incidents via Telegram, Gotify, Discord, or Email."}
</p>
</div>
</CardContent>
</Card>
{/* AI Provider Information Modal */}
<Dialog open={showProviderInfo} onOpenChange={setShowProviderInfo}>
<DialogContent className="max-w-[90vw] sm:max-w-xl md:max-w-2xl lg:max-w-3xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">AI Providers Information</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{AI_PROVIDERS.map(provider => (
<div
key={provider.value}
className="p-4 rounded-lg bg-muted/50 border border-border hover:border-muted-foreground/40 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Provider icon with theme support */}
<div className="w-10 h-10 rounded-md bg-background flex items-center justify-center border border-border shrink-0">
<img
src={resolvedTheme === 'light' ? provider.iconLight : provider.icon}
alt={provider.label}
className="w-7 h-7 object-contain"
onError={(e) => {
// Fallback if icon fails to load
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
</div>
<span className="font-medium text-sm sm:text-base">{provider.label}</span>
</div>
{provider.value === "ollama" && (
<Badge variant="outline" className="text-xs px-2 py-0.5">Local</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
{provider.description}
</p>
<p className="text-xs text-muted-foreground/70 mt-1 ml-[52px]">
Click &apos;Load&apos; to fetch available models from this provider.
</p>
{/* OpenAI compatibility note */}
{provider.value === "openai" && (
<div className="mt-3 ml-[52px] p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
<p className="text-xs sm:text-sm text-blue-400 font-medium mb-1">OpenAI-Compatible APIs</p>
<p className="text-xs text-muted-foreground leading-relaxed">
You can use any OpenAI-compatible API by setting a custom Base URL. Compatible services include:
</p>
<ul className="text-xs text-muted-foreground mt-1.5 space-y-0.5 ml-3">
<li>BytePlus/ByteDance (Kimi K2.5)</li>
<li>LocalAI, LM Studio, vLLM</li>
<li>Together AI, Fireworks AI</li>
<li>Any service using OpenAI format</li>
</ul>
</div>
)}
</div>
))}
</div>
</DialogContent>
</Dialog>
{/* Telegram Setup Guide Modal */}
<Dialog open={showTelegramHelp} onOpenChange={setShowTelegramHelp}>
<DialogContent className="max-w-[90vw] sm:max-w-xl md:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">Telegram Bot Setup Guide</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1 text-sm">
{/* Step 1 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-blue-600 text-white text-xs font-bold flex items-center justify-center">1</span>
<h4 className="font-medium">Create a Bot with BotFather</h4>
</div>
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
<p>1. Open Telegram and search for <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@BotFather</a></p>
<p>2. Send the command <code className="bg-muted px-1 rounded">/newbot</code></p>
<p>3. Choose a name for your bot (e.g., "ProxMenux Notifications")</p>
<p>4. Choose a username ending in "bot" (e.g., "proxmenux_alerts_bot")</p>
</div>
</div>
{/* Step 2 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-blue-600 text-white text-xs font-bold flex items-center justify-center">2</span>
<h4 className="font-medium">Get the Bot Token</h4>
</div>
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
<p>After creating the bot, BotFather will give you a token like:</p>
<code className="block bg-muted px-2 py-1 rounded text-[11px] mt-1">xxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</code>
<p className="mt-1">Copy this token and paste it in the <strong>Bot Token</strong> field.</p>
</div>
</div>
{/* Step 3 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-blue-600 text-white text-xs font-bold flex items-center justify-center">3</span>
<h4 className="font-medium">Get Your Chat ID</h4>
</div>
<div className="ml-8 space-y-2 text-muted-foreground text-xs">
<p className="font-medium text-foreground/80">Option A: Using a Bot (Easiest)</p>
<p>1. Search for <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@userinfobot</a> or <a href="https://t.me/getmyid_bot" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@getmyid_bot</a> on Telegram</p>
<p>2. Send any message and it will reply with your Chat ID</p>
<p className="font-medium text-foreground/80 mt-2">Option B: Manual Method</p>
<p>1. Send a message to your new bot</p>
<p>2. Open this URL in your browser (replace YOUR_TOKEN):</p>
<code className="block bg-muted px-2 py-1 rounded text-[11px] break-all">https://api.telegram.org/botYOUR_TOKEN/getUpdates</code>
<p>3. Look for <code className="bg-muted px-1 rounded">"chat":&#123;"id": XXXXXX&#125;</code> - that number is your Chat ID</p>
</div>
</div>
{/* Step 4 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-blue-600 text-white text-xs font-bold flex items-center justify-center">4</span>
<h4 className="font-medium">For Groups or Channels</h4>
</div>
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
<p>1. Add your bot to the group/channel as administrator</p>
<p>2. Send a message in the group</p>
<p>3. Use the getUpdates URL method above to find the group Chat ID</p>
<p>4. Group IDs are negative numbers (e.g., <code className="bg-muted px-1 rounded">-1001234567890</code>)</p>
</div>
</div>
{/* Summary */}
<div className="mt-4 p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
<p className="text-xs text-blue-400 font-medium mb-1">Quick Summary</p>
<ul className="text-xs text-muted-foreground space-y-0.5">
<li><strong>Bot Token:</strong> Identifies your bot (from BotFather)</li>
<li><strong>Chat ID:</strong> Where to send messages (your ID or group ID)</li>
</ul>
</div>
</div>
</DialogContent>
</Dialog>
{/* Custom Prompt Info Modal */}
<Dialog open={showCustomPromptInfo} onOpenChange={setShowCustomPromptInfo}>
<DialogContent className="max-w-[90vw] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-purple-400" />
Custom Prompt Mode
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Create your own AI prompt for ProxMenux Monitor notifications
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">What is a custom prompt?</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
The prompt defines how the AI formats your notifications. With a custom prompt, you control the style, detail level, and format of all messages.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Important requirements</h4>
<ul className="text-muted-foreground text-xs space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">1.</span>
<span>Your prompt must output in this format:<br/>
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[TITLE]</code> followed by the title, then <code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[BODY]</code> followed by the message
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">2.</span>
<span>Use plain text only (no markdown) for compatibility with all channels</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">3.</span>
<span>The prompt receives raw Proxmox event data as input</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">4.</span>
<span>Define the output language in your prompt (the Language selector only applies to Default mode)</span>
</li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Getting started</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
We have added an example prompt to get you started. You can adapt it, export it to share with others, or import prompts from the community.
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: EXAMPLE_CUSTOM_PROMPT }))
setCustomPromptDraft(EXAMPLE_CUSTOM_PROMPT)
setEditingCustomPrompt(true)
setShowCustomPromptInfo(false)
}}
className="flex-1"
>
Load Example
</Button>
<Button
size="sm"
onClick={() => {
setCustomPromptDraft("")
setEditingCustomPrompt(true)
setShowCustomPromptInfo(false)
}}
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
>
Start from Scratch
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}