mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-05 20:03:48 +00:00
1801 lines
85 KiB
TypeScript
1801 lines
85 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 } 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
|
|
} from "lucide-react"
|
|
|
|
interface ChannelConfig {
|
|
enabled: boolean
|
|
rich_format?: boolean
|
|
bot_token?: string
|
|
chat_id?: string
|
|
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_key: string
|
|
ai_model: string
|
|
ai_language: string
|
|
ai_ollama_url: string
|
|
ai_openai_base_url: string
|
|
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",
|
|
model: "llama-3.3-70b-versatile",
|
|
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",
|
|
model: "gpt-4o-mini",
|
|
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)",
|
|
model: "claude-3-haiku-20240307",
|
|
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",
|
|
model: "gemini-1.5-flash",
|
|
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)",
|
|
model: "llama3.2",
|
|
description: "100% local execution. No costs, total privacy, no internet required.",
|
|
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",
|
|
model: "meta-llama/llama-3.3-70b-instruct",
|
|
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" },
|
|
]
|
|
|
|
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_key: "",
|
|
ai_model: "",
|
|
ai_language: "en",
|
|
ai_ollama_url: "http://localhost:11434",
|
|
ai_openai_base_url: "",
|
|
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 [ollamaModels, setOllamaModels] = useState<string[]>([])
|
|
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false)
|
|
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) {
|
|
setConfig(data.config)
|
|
setOriginalConfig(data.config)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load notification settings:", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ success: boolean } & ServiceStatus>("/api/notifications/status")
|
|
if (data.success) {
|
|
setStatus(data)
|
|
}
|
|
} catch {
|
|
// Service may not be running yet
|
|
}
|
|
}, [])
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
|
if (data.success) {
|
|
setHistory(data.history || [])
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadConfig()
|
|
loadStatus()
|
|
}, [loadConfig, loadStatus])
|
|
|
|
useEffect(() => {
|
|
if (showHistory) loadHistory()
|
|
}, [showHistory, loadHistory])
|
|
|
|
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
|
|
setConfig(prev => {
|
|
const next = updater(prev)
|
|
setHasChanges(true)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const updateChannel = (channel: string, field: string, value: string | boolean) => {
|
|
updateConfig(prev => ({
|
|
...prev,
|
|
channels: {
|
|
...prev.channels,
|
|
[channel]: { ...prev.channels[channel], [field]: value },
|
|
},
|
|
}))
|
|
}
|
|
|
|
/** 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_api_key: cfg.ai_api_key,
|
|
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,
|
|
hostname: cfg.hostname,
|
|
webhook_secret: cfg.webhook_secret,
|
|
webhook_allowed_ips: cfg.webhook_allowed_ips,
|
|
pbs_host: cfg.pbs_host,
|
|
pve_host: cfg.pve_host,
|
|
pbs_trusted_sources: cfg.pbs_trusted_sources,
|
|
}
|
|
// Flatten channels: { telegram: { enabled, bot_token, chat_id } } -> telegram.enabled, telegram.bot_token, ...
|
|
for (const [chName, chCfg] of Object.entries(cfg.channels)) {
|
|
for (const [field, value] of Object.entries(chCfg)) {
|
|
flat[`${chName}.${field}`] = String(value ?? "")
|
|
}
|
|
}
|
|
// 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 fetchOllamaModels = useCallback(async (url: string) => {
|
|
if (!url) return
|
|
setLoadingOllamaModels(true)
|
|
try {
|
|
const data = await fetchApi<{ success: boolean; models: string[]; message: string }>("/api/notifications/ollama-models", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ollama_url: url }),
|
|
})
|
|
if (data.success && data.models && data.models.length > 0) {
|
|
setOllamaModels(data.models)
|
|
// Auto-select first model if current selection is empty or not in the list
|
|
updateConfig(prev => {
|
|
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
|
|
return { ...prev, ai_model: data.models[0] }
|
|
}
|
|
return prev
|
|
})
|
|
} else {
|
|
setOllamaModels([])
|
|
}
|
|
} catch {
|
|
setOllamaModels([])
|
|
} finally {
|
|
setLoadingOllamaModels(false)
|
|
}
|
|
}, [])
|
|
|
|
// Note: We removed the automatic useEffect that fetched models on URL change
|
|
// because it caused infinite loops. Users now use the "Load" button explicitly.
|
|
|
|
const handleTestAI = async () => {
|
|
setTestingAI(true)
|
|
setAiTestResult(null)
|
|
try {
|
|
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: config.ai_api_key,
|
|
model: config.ai_model,
|
|
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"
|
|
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
|
value={config.channels.telegram?.bot_token || ""}
|
|
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
|
/>
|
|
<button
|
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
|
onClick={() => toggleSecret("tg_token")}
|
|
>
|
|
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">Chat ID</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="-1001234567890"
|
|
value={config.channels.telegram?.chat_id || ""}
|
|
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
|
/>
|
|
</div>
|
|
{/* 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"
|
|
placeholder="https://gotify.example.com"
|
|
value={config.channels.gotify?.url || ""}
|
|
onChange={e => updateChannel("gotify", "url", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">App Token</Label>
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
type={showSecrets["gt_token"] ? "text" : "password"}
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="A_valid_gotify_token"
|
|
value={config.channels.gotify?.token || ""}
|
|
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
|
/>
|
|
<button
|
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
|
onClick={() => toggleSecret("gt_token")}
|
|
>
|
|
{showSecrets["gt_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* 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"
|
|
placeholder="https://discord.com/api/webhooks/..."
|
|
value={config.channels.discord?.webhook_url || ""}
|
|
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
|
/>
|
|
<button
|
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
|
onClick={() => toggleSecret("dc_hook")}
|
|
>
|
|
{showSecrets["dc_hook"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* 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"
|
|
placeholder="smtp.gmail.com"
|
|
value={config.channels.email?.host || ""}
|
|
onChange={e => updateChannel("email", "host", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">Port</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="587"
|
|
value={config.channels.email?.port || ""}
|
|
onChange={e => updateChannel("email", "port", e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">TLS Mode</Label>
|
|
<Select
|
|
value={config.channels.email?.tls_mode || "starttls"}
|
|
onValueChange={v => updateChannel("email", "tls_mode", v)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="starttls">STARTTLS (port 587)</SelectItem>
|
|
<SelectItem value="ssl">SSL/TLS (port 465)</SelectItem>
|
|
<SelectItem value="none">None (port 25)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">Username</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="user@example.com"
|
|
value={config.channels.email?.username || ""}
|
|
onChange={e => updateChannel("email", "username", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">Password</Label>
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
type={showSecrets["em_pass"] ? "text" : "password"}
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="App password"
|
|
value={config.channels.email?.password || ""}
|
|
onChange={e => updateChannel("email", "password", e.target.value)}
|
|
/>
|
|
<button
|
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
|
onClick={() => toggleSecret("em_pass")}
|
|
>
|
|
{showSecrets["em_pass"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">From Address</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="proxmenux@yourdomain.com"
|
|
value={config.channels.email?.from_address || ""}
|
|
onChange={e => updateChannel("email", "from_address", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">To Addresses (comma-separated)</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="admin@example.com, ops@example.com"
|
|
value={config.channels.email?.to_addresses || ""}
|
|
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">Subject Prefix</Label>
|
|
<Input
|
|
className="h-7 text-xs font-mono"
|
|
placeholder="[ProxMenux]"
|
|
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
|
|
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
|
|
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
|
|
<p className="text-[10px] text-amber-400/90 leading-relaxed">
|
|
Leave SMTP Host empty to use local sendmail (must be installed on the server).
|
|
For Gmail, use an App Password instead of your account password.
|
|
</p>
|
|
</div>
|
|
{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>
|
|
<button
|
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full py-1"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
>
|
|
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
|
|
{config.ai_enabled && (
|
|
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
|
|
ON
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
|
|
<div className="flex items-center justify-between">
|
|
<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>
|
|
<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">
|
|
<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 => updateConfig(p => ({ ...p, ai_provider: v }))}
|
|
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>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
className="h-9 text-sm font-mono flex-1"
|
|
placeholder="http://localhost:11434"
|
|
value={config.ai_ollama_url}
|
|
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
|
|
disabled={!editMode}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 px-3 shrink-0"
|
|
onClick={() => fetchOllamaModels(config.ai_ollama_url)}
|
|
disabled={loadingOllamaModels || !config.ai_ollama_url}
|
|
>
|
|
{loadingOllamaModels ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<RefreshCw className="h-4 w-4 mr-1" />
|
|
Load
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{ollamaModels.length > 0 && (
|
|
<p className="text-xs text-green-500">{ollamaModels.length} models found</p>
|
|
)}
|
|
</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_key}
|
|
onChange={e => updateConfig(p => ({ ...p, ai_api_key: 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 for Ollama, read-only for others */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
|
|
{config.ai_provider === "ollama" ? (
|
|
<Select
|
|
value={config.ai_model || ""}
|
|
onValueChange={v => updateConfig(p => ({ ...p, ai_model: v }))}
|
|
disabled={!editMode || loadingOllamaModels || ollamaModels.length === 0}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm font-mono">
|
|
<SelectValue placeholder={ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
|
|
{config.ai_model || (ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ollamaModels.length > 0 ? (
|
|
ollamaModels.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>
|
|
) : (
|
|
<div className="h-9 px-3 flex items-center rounded-md border border-border bg-muted/50 text-sm font-mono text-muted-foreground">
|
|
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || "default"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language selector */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
|
|
<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>
|
|
|
|
{/* Test Connection button */}
|
|
<button
|
|
onClick={handleTestAI}
|
|
disabled={!editMode || testingAI || (config.ai_provider !== "ollama" && !config.ai_api_key)}
|
|
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>
|
|
)}
|
|
|
|
{/* Per-channel detail level */}
|
|
<div className="space-y-3 pt-3 border-t border-border/50">
|
|
<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>
|
|
|
|
<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 enhancement translates and formats notifications to your selected language. Each channel can have different detail levels. If the AI service is unavailable, standard templates are used as fallback.
|
|
</p>
|
|
</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>
|
|
<div className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px]">
|
|
Default model: <code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{provider.model}</code>
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
|
|
{provider.description}
|
|
</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">{"<bot_id>:<bot_secret>"}</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":{"id": XXXXXX}</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>
|
|
</>
|
|
)
|
|
}
|