mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
2806 lines
137 KiB
TypeScript
2806 lines
137 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 { useT } from "../lib/i18n/provider"
|
|
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,
|
|
Moon, Newspaper
|
|
} 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
|
|
// Quiet hours: skip below-CRITICAL events between [start, end) local time
|
|
quiet_enabled?: boolean
|
|
quiet_start?: string // "HH:MM"
|
|
quiet_end?: string // "HH:MM"
|
|
// Daily digest: buffer INFO events and ship one summary at digest_time
|
|
digest_enabled?: boolean
|
|
digest_time?: string // "HH:MM"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Validation helpers for webhook/URL fields. The server still does the
|
|
// authoritative validation (see notification_manager.validate_config). These
|
|
// are defense-in-depth + immediate UX feedback so users notice typos / pasted
|
|
// internal endpoints before they hit Save.
|
|
const DISCORD_WEBHOOK_RE = /^https:\/\/(discord(app)?\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[\w-]+$/
|
|
|
|
function validateDiscordWebhook(url: string): { error?: string } {
|
|
if (!url) return {}
|
|
if (!DISCORD_WEBHOOK_RE.test(url.trim())) {
|
|
return { error: "settings.notifications.validation.discordWebhook" }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
|
if (!url) return {}
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(url.trim())
|
|
} catch {
|
|
return { error: "settings.notifications.validation.invalidUrl" }
|
|
}
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
return { error: "settings.notifications.validation.unsupportedScheme" }
|
|
}
|
|
// Block the obvious SSRF target: the local PVE API. RFC1918 ranges remain
|
|
// allowed since self-hosted Gotify on a LAN is a normal deployment.
|
|
const host = parsed.hostname.toLowerCase()
|
|
const port = parsed.port
|
|
if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && (port === "8006" || port === "8007")) {
|
|
return { error: "settings.notifications.validation.localPveApi" }
|
|
}
|
|
if (host === "169.254.169.254") {
|
|
return { error: "settings.notifications.validation.metadataIp" }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "other"].map(key => ({ key }))
|
|
|
|
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "apprise"] as const
|
|
|
|
const AI_PROVIDERS = [
|
|
{
|
|
value: "groq",
|
|
label: "Groq",
|
|
keyUrl: "https://console.groq.com/keys",
|
|
icon: "/icons/Groq Logo_White 25.svg",
|
|
iconLight: "/icons/Groq Logo_Black 25.svg"
|
|
},
|
|
{
|
|
value: "openai",
|
|
label: "OpenAI",
|
|
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)",
|
|
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",
|
|
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",
|
|
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",
|
|
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: "sk", label: "Slovenčina" },
|
|
{ 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 = ["brief", "standard", "detailed"]
|
|
|
|
// 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 },
|
|
apprise: { 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: {} },
|
|
apprise: { 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",
|
|
apprise: "brief",
|
|
},
|
|
hostname: "",
|
|
webhook_secret: "",
|
|
webhook_allowed_ips: "",
|
|
pbs_host: "",
|
|
pve_host: "",
|
|
pbs_trusted_sources: "",
|
|
}
|
|
|
|
export function NotificationSettings() {
|
|
const t = useT()
|
|
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)
|
|
// Save errors used to be silently swallowed — the user thought their
|
|
// tokens / API keys were persisted when in fact the POST had failed.
|
|
// Surface the failure as a banner so the user can retry. Audit residual
|
|
// #notification-settings-handleSave-silent-fail.
|
|
const [saveError, setSaveError] = useState<string | null>(null)
|
|
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>>({})
|
|
// Cleartext secrets cached only while the eye toggle is "on" for
|
|
// that field. Settings GET returns "************" for everything in
|
|
// SENSITIVE_KEYS; clicking eye fetches the real value via
|
|
// /api/notifications/reveal-secret and stores it here. Cleared when
|
|
// the user toggles eye off, or on every reload — never persists.
|
|
const [revealedSecrets, setRevealedSecrets] = useState<Record<string, string>>({})
|
|
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 [systemHostname, setSystemHostname] = useState<string>("")
|
|
// Mirrors the dedicated toggle from Settings → LXC Update Detection.
|
|
// When false, the per-event toggle for `lxc_updates_available` is hidden
|
|
// from every channel's category list (its DB preference is preserved).
|
|
// Updated on mount via fetch and on the fly via a CustomEvent dispatched
|
|
// by <LxcUpdateDetection /> when the user flips the switch.
|
|
const [lxcDetectionEnabled, setLxcDetectionEnabled] = useState<boolean>(true)
|
|
|
|
// Load system hostname for display name placeholder
|
|
const loadSystemHostname = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ hostname?: string }>("/api/system")
|
|
if (data.hostname) {
|
|
setSystemHostname(data.hostname)
|
|
}
|
|
} catch {
|
|
// Ignore - will show generic placeholder
|
|
}
|
|
}, [])
|
|
|
|
const loadConfig = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings")
|
|
if (data.success && data.config) {
|
|
// Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs)
|
|
const configWithDefaults = {
|
|
...data.config,
|
|
ai_api_keys: data.config.ai_api_keys || {
|
|
groq: "",
|
|
ollama: "",
|
|
gemini: "",
|
|
anthropic: "",
|
|
openai: "",
|
|
openrouter: "",
|
|
},
|
|
ai_models: data.config.ai_models || {
|
|
groq: "",
|
|
ollama: "",
|
|
gemini: "",
|
|
anthropic: "",
|
|
openai: "",
|
|
openrouter: "",
|
|
},
|
|
ai_prompt_mode: data.config.ai_prompt_mode || "default",
|
|
ai_custom_prompt: data.config.ai_custom_prompt || "",
|
|
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
|
|
}
|
|
// If ai_model exists but ai_models doesn't have it, save it
|
|
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
|
|
configWithDefaults.ai_models[configWithDefaults.ai_provider] = configWithDefaults.ai_model
|
|
}
|
|
setConfig(configWithDefaults)
|
|
setOriginalConfig(configWithDefaults)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load notification settings:", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ success: boolean } & ServiceStatus>("/api/notifications/status")
|
|
if (data.success) {
|
|
setStatus(data)
|
|
}
|
|
} catch {
|
|
// Service may not be running yet
|
|
}
|
|
}, [])
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
try {
|
|
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
|
if (data.success) {
|
|
setHistory(data.history || [])
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadConfig()
|
|
loadStatus()
|
|
loadSystemHostname()
|
|
}, [loadConfig, loadStatus, loadSystemHostname])
|
|
|
|
// Track the LXC update-detection toggle so we can conditionally hide
|
|
// the `lxc_updates_available` per-event toggle inside every channel's
|
|
// category list. Fetched once on mount; live updates ride on a custom
|
|
// event dispatched by <LxcUpdateDetection /> whenever the user flips
|
|
// the switch upstream.
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
fetchApi<{ success: boolean; enabled?: boolean }>("/api/lxc-updates/detection")
|
|
.then(data => {
|
|
if (cancelled) return
|
|
if (data.success && typeof data.enabled === "boolean") {
|
|
setLxcDetectionEnabled(data.enabled)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Default-true on fetch failure — matches the backend default and
|
|
// avoids hiding a notification toggle the user might rely on if
|
|
// the settings endpoint is transiently unreachable.
|
|
})
|
|
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail
|
|
if (detail && typeof detail.enabled === "boolean") {
|
|
setLxcDetectionEnabled(detail.enabled)
|
|
}
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("proxmenux:lxc-detection-changed", handler)
|
|
}
|
|
return () => {
|
|
cancelled = true
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("proxmenux:lxc-detection-changed", handler)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
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 },
|
|
},
|
|
}))
|
|
}
|
|
|
|
const formatHHMM = (raw: string | undefined, fallback: string): string => {
|
|
const v = (raw || fallback).match(/^(\d{1,2}):(\d{2})$/)
|
|
if (!v) return fallback
|
|
const hh = String(Math.min(23, Math.max(0, parseInt(v[1], 10)))).padStart(2, "0")
|
|
const mm = String(Math.min(59, Math.max(0, parseInt(v[2], 10)))).padStart(2, "0")
|
|
return `${hh}:${mm}`
|
|
}
|
|
|
|
const inQuietWindow = (start: string, end: string): boolean => {
|
|
if (start === end) return false
|
|
const now = new Date()
|
|
const cur = now.getHours() * 60 + now.getMinutes()
|
|
const [sh, sm] = start.split(":").map((x) => parseInt(x, 10))
|
|
const [eh, em] = end.split(":").map((x) => parseInt(x, 10))
|
|
const s = sh * 60 + sm
|
|
const e = eh * 60 + em
|
|
return s < e ? cur >= s && cur < e : cur >= s || cur < e
|
|
}
|
|
|
|
const renderQuietHours = (chName: string) => {
|
|
const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined
|
|
const enabled = !!ch?.quiet_enabled
|
|
const start = formatHHMM(ch?.quiet_start, "22:00")
|
|
const end = formatHHMM(ch?.quiet_end, "06:00")
|
|
const sameTime = start === end
|
|
const live = enabled && !sameTime && inQuietWindow(start, end)
|
|
return (
|
|
<div className="space-y-2 pt-2 border-t border-border/50">
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm text-foreground/80 flex items-center gap-2">
|
|
<Moon className="h-4 w-4 text-blue-400" />
|
|
{t("settings.notifications.ui.quietHours")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("settings.notifications.ui.quietHoursDescription")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={enabled}
|
|
disabled={!editMode}
|
|
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
|
|
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
|
} ${enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
|
|
onClick={() => { if (editMode) updateChannel(chName, "quiet_enabled", !enabled) }}
|
|
>
|
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
enabled ? "translate-x-[18px]" : "translate-x-0"
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{enabled && (
|
|
<>
|
|
{/* Inline label + intrinsic-width inputs. The previous
|
|
`grid-cols-2 + full-width inputs` rendered weirdly on
|
|
iOS Safari (the native time picker centered "22:00"
|
|
inside a 200-px box with huge empty margins). flex +
|
|
w-24/w-28 keeps the input tight to the HH:MM text on
|
|
every viewport and the touch target stays comfortable. */}
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-muted-foreground">{t("settings.notifications.ui.from")}</Label>
|
|
<Input
|
|
type="time"
|
|
value={start}
|
|
onChange={(e) => updateChannel(chName, "quiet_start", e.target.value)}
|
|
disabled={!editMode}
|
|
className="h-9 w-28 text-sm font-mono"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-muted-foreground">{t("settings.notifications.ui.until")}</Label>
|
|
<Input
|
|
type="time"
|
|
value={end}
|
|
onChange={(e) => updateChannel(chName, "quiet_end", e.target.value)}
|
|
disabled={!editMode}
|
|
className="h-9 w-28 text-sm font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{sameTime
|
|
? t("settings.notifications.ui.quietSameTime")
|
|
: live
|
|
? t("settings.notifications.ui.quietActive", { time: end })
|
|
: t("settings.notifications.ui.quietInactive", { time: start })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderDailyDigest = (chName: string) => {
|
|
const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined
|
|
const enabled = !!ch?.digest_enabled
|
|
const time = formatHHMM(ch?.digest_time, "09:00")
|
|
let nextLabel = ""
|
|
if (enabled) {
|
|
const now = new Date()
|
|
const cur = now.getHours() * 60 + now.getMinutes()
|
|
const [hh, mm] = time.split(":").map((x) => parseInt(x, 10))
|
|
const target = hh * 60 + mm
|
|
const minsAway = target > cur ? target - cur : 24 * 60 - cur + target
|
|
const h = Math.floor(minsAway / 60)
|
|
const m = minsAway % 60
|
|
nextLabel = t("settings.notifications.ui.nextDigest", { hours: h, minutes: m, time })
|
|
}
|
|
return (
|
|
<div className="space-y-2 pt-2 border-t border-border/50">
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm text-foreground/80 flex items-center gap-2">
|
|
<Newspaper className="h-4 w-4 text-violet-400" />
|
|
{t("settings.notifications.ui.dailyDigest")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("settings.notifications.ui.dailyDigestDescription")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={enabled}
|
|
disabled={!editMode}
|
|
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
|
|
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
|
} ${enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
|
|
onClick={() => { if (editMode) updateChannel(chName, "digest_enabled", !enabled) }}
|
|
>
|
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
enabled ? "translate-x-[18px]" : "translate-x-0"
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{enabled && (
|
|
<>
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Label className="text-xs text-muted-foreground">{t("settings.notifications.ui.sendAt")}</Label>
|
|
<Input
|
|
type="time"
|
|
value={time}
|
|
onChange={(e) => updateChannel(chName, "digest_time", e.target.value)}
|
|
disabled={!editMode}
|
|
className="h-9 w-28 text-sm font-mono"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{nextLabel}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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 || {}
|
|
const eventTitle = (evt: EventTypeInfo) => {
|
|
const key = `settings.notifications.eventTypes.${evt.type}`
|
|
const translated = t(key)
|
|
return translated === key ? evt.title : translated
|
|
}
|
|
|
|
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">{t("settings.notifications.ui.notificationCategories")}</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}`)
|
|
// Hide the LXC update toggle when the user has disabled the
|
|
// dedicated detection setting upstream. The backend still
|
|
// returns the event type in the catalog (so its stored
|
|
// preference survives), but we filter it out of every
|
|
// channel's UI list so the operator never sees a notification
|
|
// toggle whose underlying scan is paused.
|
|
const rawEventsForGroup = evtByGroup[cat.key] || []
|
|
const eventsForGroup = lxcDetectionEnabled
|
|
? rawEventsForGroup
|
|
: rawEventsForGroup.filter(e => e.type !== "lxc_updates_available")
|
|
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"
|
|
}`}>{t(`settings.notifications.categories.${cat.key}.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"}`}>
|
|
{eventTitle(evt)}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
const providerLabel = (provider: (typeof AI_PROVIDERS)[number]) =>
|
|
provider.value === "ollama"
|
|
? `${provider.label} (${t("settings.notifications.ai.local")})`
|
|
: provider.label
|
|
|
|
/** 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)
|
|
setSaveError(null)
|
|
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)
|
|
const msg = err instanceof Error ? err.message : t("settings.notifications.errors.saveFailed")
|
|
setSaveError(msg)
|
|
} 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
|
|
? t("settings.notifications.test.sent")
|
|
: channelResult.error || t("settings.notifications.test.failed")
|
|
}
|
|
}
|
|
if (!message && data.error) {
|
|
message = data.error
|
|
}
|
|
if (!message) {
|
|
message = data.success ? t("settings.notifications.test.sent") : t("settings.notifications.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: t("settings.notifications.ai.noModelSelected") })
|
|
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
|
|
}
|
|
}
|
|
|
|
// Maps each eye-button local key to the body shape the backend
|
|
// expects for /api/notifications/reveal-secret. Centralised here so
|
|
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
|
const SECRET_REVEAL_TARGETS: Record<string, Record<string, string>> = {
|
|
tg_token: { channel: "telegram", field: "bot_token" },
|
|
gt_token: { channel: "gotify", field: "token" },
|
|
dc_hook: { channel: "discord", field: "webhook_url" },
|
|
em_pass: { channel: "email", field: "password" },
|
|
apprise_url: { channel: "apprise", field: "url" },
|
|
}
|
|
|
|
const toggleSecret = async (key: string) => {
|
|
// Turning eye OFF — drop the cached cleartext immediately.
|
|
if (showSecrets[key]) {
|
|
setShowSecrets(prev => ({ ...prev, [key]: false }))
|
|
setRevealedSecrets(prev => {
|
|
const next = { ...prev }
|
|
delete next[key]
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
|
|
// Turning eye ON — resolve the backend body. ai_key is dynamic
|
|
// (depends on current provider); the rest are static.
|
|
let body: Record<string, string> | null = null
|
|
if (key === "ai_key") {
|
|
const prov = (config.ai_provider || "").trim()
|
|
if (prov) body = { ai_provider: prov }
|
|
} else {
|
|
body = SECRET_REVEAL_TARGETS[key] || null
|
|
}
|
|
|
|
if (!body) {
|
|
// Unknown eye-toggle — flip state and let the input render
|
|
// whatever it already has. Never silently skip the UI feedback.
|
|
setShowSecrets(prev => ({ ...prev, [key]: true }))
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Must go through `fetchApi` (not raw `fetch`) so the JWT
|
|
// Authorization header gets attached — the endpoint uses
|
|
// `@require_auth` and would reject a plain fetch with 401, which
|
|
// is exactly what produced the "still asterisks" behaviour seen
|
|
// on the first build.
|
|
const data = await fetchApi<{ value?: string }>("/api/notifications/reveal-secret", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const value = typeof data?.value === "string" ? data.value : ""
|
|
setRevealedSecrets(prev => ({ ...prev, [key]: value }))
|
|
} catch {
|
|
// Network/auth failure → still flip the eye so the user sees
|
|
// the underlying input changing (and that it has the masked
|
|
// placeholder, which signals "not loaded"). Don't crash.
|
|
}
|
|
setShowSecrets(prev => ({ ...prev, [key]: true }))
|
|
}
|
|
|
|
// Sentinel the backend sends in place of any populated sensitive
|
|
// value (see SENSITIVE_PLACEHOLDER in notification_manager.py).
|
|
const SECRET_PLACEHOLDER = "************"
|
|
|
|
// Render value for a secret input:
|
|
// - if the eye is on AND the stored value is the masked placeholder
|
|
// AND we have a revealed cleartext for this key → show cleartext.
|
|
// - otherwise show whatever the input already has (the placeholder
|
|
// while masked, or the value the user is typing in editMode).
|
|
// This avoids overwriting an in-progress edit when the eye toggles.
|
|
const secretValue = (key: string, current: string): string => {
|
|
if (
|
|
showSecrets[key] &&
|
|
current === SECRET_PLACEHOLDER &&
|
|
revealedSecrets[key] !== undefined
|
|
) {
|
|
return revealedSecrets[key]
|
|
}
|
|
return current
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-5 w-5 text-blue-500" />
|
|
<CardTitle>{t("settings.notifications.title")}</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 || t("settings.notifications.errors.unknown"),
|
|
})
|
|
}
|
|
} catch {
|
|
setWebhookSetup({
|
|
status: "failed",
|
|
fallback_commands: [],
|
|
error: t("settings.notifications.errors.setupUnavailable"),
|
|
})
|
|
}
|
|
|
|
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>{t("settings.notifications.title")}</CardTitle>
|
|
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
|
{t("status.disabled")}
|
|
</Badge>
|
|
</div>
|
|
<CardDescription>
|
|
{t("settings.notifications.disabledDescription")}
|
|
</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">{t("settings.notifications.enableService")}</p>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t("settings.notifications.enableServiceDescription")}
|
|
</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 ? t("settings.notifications.configuring") : t("settings.notifications.enableButton")}
|
|
</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">
|
|
{t("settings.notifications.webhookConfigured")}
|
|
</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">
|
|
{t("settings.notifications.webhookFailed", { error: webhookSetup.error })}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("settings.notifications.webhookManualSetup")}
|
|
</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>{t("settings.notifications.title")}</CardTitle>
|
|
{config.enabled && (
|
|
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
|
{t("status.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" />
|
|
{t("status.saved")}
|
|
</span>
|
|
)}
|
|
{saveError && (
|
|
<span
|
|
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
|
title={saveError}
|
|
>
|
|
{t("status.saveFailed")}: {saveError}
|
|
</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}
|
|
>
|
|
{t("actions.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" />}
|
|
{t("actions.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" />
|
|
{t("actions.edit")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<CardDescription>
|
|
{t("settings.notifications.description")}
|
|
</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 ? t("settings.notifications.serviceRunning") : t("settings.notifications.serviceStopped")}
|
|
</span>
|
|
{status.total_sent_24h > 0 && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
{t("settings.notifications.sentLast24h", { count: status.total_sent_24h })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{activeChannels > 0 && (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{t(activeChannels === 1 ? "settings.notifications.channelCountOne" : "settings.notifications.channelCountMany", { count: activeChannels })}
|
|
</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">{t("settings.notifications.enableButton")}</span>
|
|
<p className="text-[11px] text-muted-foreground">{t("settings.notifications.activateService")}</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">{t("settings.notifications.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-5 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>
|
|
<TabsTrigger value="apprise" className="text-xs data-[state=active]:text-cyan-500">
|
|
Apprise
|
|
</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">{t("settings.notifications.ui.enableTelegram")}</Label>
|
|
<button
|
|
onClick={() => setShowTelegramHelp(true)}
|
|
className="text-[10px] text-blue-500 hover:text-blue-400 hover:underline"
|
|
>
|
|
{t("settings.notifications.ui.setupGuide")}
|
|
</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">{t("settings.notifications.ui.botToken")}</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={secretValue("tg_token", 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">{t("settings.notifications.ui.chatId")}</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">{t("settings.notifications.ui.topicId")} <span className="text-muted-foreground/60">({t("settings.notifications.ui.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">{t("settings.notifications.ui.topicHint")}</p>
|
|
</div>
|
|
{/* Message format */}
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<Label className="text-xs font-medium">{t("settings.notifications.ui.richMessages")}</Label>
|
|
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.richMessagesDescription")}</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")}
|
|
{renderQuietHours("telegram")}
|
|
{renderDailyDigest("telegram")}
|
|
{/* Send Test — channel-colored button (#226). All five
|
|
channels follow the same `bg-<channel>-600 ... text-white`
|
|
pattern; the color matches the active-tab tint above. */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
|
<button
|
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors 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" />}
|
|
{t("settings.notifications.ui.sendTest")}
|
|
</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">{t("settings.notifications.ui.enableGotify")}</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">{t("settings.notifications.ui.serverUrl")}</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}
|
|
/>
|
|
{(() => {
|
|
const v = validateGotifyUrl(config.channels.gotify?.url || "")
|
|
if (v.error) return <p className="text-[10px] text-red-500">{t(v.error)}</p>
|
|
if (v.warning) return <p className="text-[10px] text-yellow-500">{v.warning}</p>
|
|
return null
|
|
})()}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.appToken")}</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={secretValue("gt_token", 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">{t("settings.notifications.ui.richMessages")}</Label>
|
|
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.richMessagesDescription")}</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")}
|
|
{renderQuietHours("gotify")}
|
|
{renderDailyDigest("gotify")}
|
|
{/* Send Test — channel-colored (see Telegram block). */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
|
<button
|
|
className="h-7 px-3 text-xs rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors 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" />}
|
|
{t("settings.notifications.ui.sendTest")}
|
|
</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">{t("settings.notifications.ui.enableDiscord")}</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">{t("settings.notifications.ui.webhookUrl")}</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={secretValue("dc_hook", 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>
|
|
{(() => {
|
|
const v = validateDiscordWebhook(config.channels.discord?.webhook_url || "")
|
|
return v.error ? <p className="text-[10px] text-red-500">{t(v.error)}</p> : null
|
|
})()}
|
|
</div>
|
|
{/* Message format */}
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<Label className="text-xs font-medium">{t("settings.notifications.ui.richMessages")}</Label>
|
|
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.richMessagesDescription")}</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")}
|
|
{renderQuietHours("discord")}
|
|
{renderDailyDigest("discord")}
|
|
{/* Send Test — channel-colored (see Telegram block). */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
|
<button
|
|
className="h-7 px-3 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 text-white 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" />}
|
|
{t("settings.notifications.ui.sendTest")}
|
|
</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">{t("settings.notifications.ui.enableEmail")}</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">{t("settings.notifications.ui.smtpHost")}</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">{t("settings.notifications.ui.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">{t("settings.notifications.ui.tlsMode")}</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">{t("settings.notifications.ui.noTls")}</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">{t("settings.notifications.ui.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">{t("settings.notifications.ui.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={t("settings.notifications.ui.appPassword")}
|
|
value={secretValue("em_pass", 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">{t("settings.notifications.ui.fromAddress")}</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">{t("settings.notifications.ui.toAddresses")}</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">{t("settings.notifications.ui.subjectPrefix")}</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">
|
|
{t("settings.notifications.ui.emailHint")}
|
|
</p>
|
|
</div>
|
|
{renderChannelCategories("email")}
|
|
{renderQuietHours("email")}
|
|
{renderDailyDigest("email")}
|
|
{/* Send Test — channel-colored (see Telegram block). */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
|
<button
|
|
className="h-7 px-3 text-xs rounded-md bg-amber-600 hover:bg-amber-700 text-white 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" />}
|
|
{t("settings.notifications.ui.sendTest")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Apprise — issue #207. Single URL talks to ~80
|
|
notification services. The operator pastes one
|
|
`tgram://`, `discord://`, `ntfy://`, `matrix://`,
|
|
`pushover://` etc. URL and the AppriseChannel
|
|
backend handles the transport. Mirrors the same
|
|
Enable toggle + Test button pattern as the other
|
|
channels. */}
|
|
<TabsContent value="apprise" 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">{t("settings.notifications.ui.enableApprise")}</Label>
|
|
<a
|
|
href="https://github.com/caronc/apprise/wiki"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[10px] text-cyan-500 hover:text-cyan-400 hover:underline"
|
|
>
|
|
{t("settings.notifications.ui.urlFormats")}
|
|
</a>
|
|
</div>
|
|
<button
|
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
|
config.channels.apprise?.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("apprise", "enabled", !config.channels.apprise?.enabled) }}
|
|
disabled={!editMode}
|
|
role="switch"
|
|
aria-checked={config.channels.apprise?.enabled || false}
|
|
>
|
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
config.channels.apprise?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{config.channels.apprise?.enabled && (
|
|
<>
|
|
<div className="space-y-1.5 min-w-0">
|
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.appriseUrl")}</Label>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Input
|
|
type={showSecrets["apprise_url"] ? "text" : "password"}
|
|
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
|
placeholder="tgram://bottoken/ChatID"
|
|
value={secretValue("apprise_url", config.channels.apprise?.url || "")}
|
|
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
|
disabled={!editMode}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
|
onClick={() => toggleSecret("apprise_url")}
|
|
title={t(showSecrets["apprise_url"] ? "settings.notifications.ui.hideUrl" : "settings.notifications.ui.showUrl")}
|
|
>
|
|
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
{/* The examples row was overflowing on mobile because
|
|
every `<code>` token is atomic — the whole line
|
|
would scroll horizontally on narrow viewports.
|
|
`break-all` on the wrapper lets the layout break
|
|
mid-token if the viewport is really tight; on
|
|
wider screens the natural commas/spaces still
|
|
control wrapping. */}
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed break-all min-w-0">
|
|
{t("settings.notifications.ui.appriseDescription")}
|
|
<code className="text-foreground/80 mx-0.5">tgram://</code>,
|
|
<code className="text-foreground/80 mx-0.5">discord://</code>,
|
|
<code className="text-foreground/80 mx-0.5">slack://</code>,
|
|
<code className="text-foreground/80 mx-0.5">ntfy://</code>,
|
|
<code className="text-foreground/80 mx-0.5">matrix://</code>,
|
|
<code className="text-foreground/80 mx-0.5">pushover://</code>,
|
|
<code className="text-foreground/80 mx-0.5">mailto://</code>… {t("settings.notifications.ui.seeThe")}
|
|
{" "}
|
|
<a
|
|
href="https://github.com/caronc/apprise/wiki"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-cyan-500 hover:underline"
|
|
>
|
|
{t("settings.notifications.ui.fullList")}
|
|
</a>.
|
|
</p>
|
|
</div>
|
|
{renderChannelCategories("apprise")}
|
|
{renderQuietHours("apprise")}
|
|
{renderDailyDigest("apprise")}
|
|
{/* Send Test — left-aligned + channel-colored, matching
|
|
the other four channels (was right-aligned, #226). */}
|
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
|
<button
|
|
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
|
|
onClick={() => handleTest("apprise")}
|
|
disabled={testing === "apprise" || !config.channels.apprise?.url}
|
|
>
|
|
{testing === "apprise" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
|
{t("settings.notifications.ui.sendTest")}
|
|
</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>
|
|
|
|
{/* ── Display Name ── */}
|
|
<div className="space-y-2 pb-3 border-b border-border/50">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="h-4 w-4 text-blue-400" />
|
|
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ui.displayName")}</Label>
|
|
</div>
|
|
<Input
|
|
className={`h-9 text-sm ${!editMode ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
placeholder={systemHostname || t("settings.notifications.ui.systemHostname")}
|
|
value={config.hostname || (editMode ? "" : systemHostname)}
|
|
onChange={e => updateConfig(p => ({ ...p, hostname: e.target.value }))}
|
|
disabled={!editMode}
|
|
readOnly={!editMode}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.notifications.ui.displayNameHint")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Advanced: AI Enhancement ── */}
|
|
<div>
|
|
<div className="flex items-center justify-between py-1">
|
|
<button
|
|
className="flex items-center gap-2 text-sm text-foreground hover:bg-muted/60 rounded-md px-2 py-1.5 -mx-2 transition-colors"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
>
|
|
{showAdvanced ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<Sparkles className="h-4 w-4 text-purple-400" />
|
|
<span className="font-medium">{t("settings.notifications.ai.title")}</span>
|
|
{config.ai_enabled ? (
|
|
<Badge variant="outline" className="text-[10px] border-purple-500/40 text-purple-400 ml-1">
|
|
{t("status.active")}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-[10px] border-border text-muted-foreground ml-1">
|
|
{t("settings.notifications.ui.optional")}
|
|
</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}
|
|
>
|
|
{t("actions.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" />}
|
|
{t("actions.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" />
|
|
{t("actions.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">{t("settings.notifications.ai.enhancedMessages")}</span>
|
|
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</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">{t("settings.notifications.ai.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}>{providerLabel(p)}</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">{t("settings.notifications.ai.ollamaUrl")}</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">{t("settings.notifications.ai.customBaseUrl")}</Label>
|
|
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
|
|
</div>
|
|
<Input
|
|
className="h-9 text-sm font-mono"
|
|
placeholder={t("settings.notifications.ai.customBaseUrlPlaceholder")}
|
|
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">
|
|
{t("settings.notifications.ai.customBaseUrlHint")}
|
|
</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">{t("settings.notifications.ai.apiKey")}</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"
|
|
>
|
|
{t("settings.notifications.ai.getKey")} <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={secretValue("ai_key", 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">{t("settings.notifications.ai.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={t(providerModels.length === 0 ? "settings.notifications.ai.loadModelsPlaceholder" : "settings.notifications.ai.selectModel")}>
|
|
{config.ai_model || t(providerModels.length === 0 ? "settings.notifications.ai.loadModelsPlaceholder" : "settings.notifications.ai.selectModel")}
|
|
</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">
|
|
{t("settings.notifications.ai.noModelsLoaded")}
|
|
</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" />
|
|
{t("settings.notifications.ai.load")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{providerModels.length > 0 && (
|
|
<p className="text-xs text-green-500">{t("settings.notifications.ai.modelsAvailable", { count: providerModels.length })}</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">{t("settings.notifications.ai.promptMode")}</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">{t("settings.notifications.ai.defaultPrompt")}</SelectItem>
|
|
<SelectItem value="custom">{t("settings.notifications.ai.customPrompt")}</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">{t("settings.notifications.ai.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={t("settings.notifications.ai.selectLanguage")}>
|
|
{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">{t("settings.notifications.ai.detailLevel")}</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(level => (
|
|
<SelectItem key={level} value={level} className="text-xs">
|
|
{t(`settings.notifications.ai.detailLevels.${level}`)}
|
|
</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">
|
|
{t("settings.notifications.ai.detailHint")}
|
|
</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">{t("settings.notifications.ai.suggestions")}</span>
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium">{t("settings.notifications.ai.beta")}</span>
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
{t("settings.notifications.ai.suggestionsDescription")}
|
|
</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">{t("settings.notifications.ai.customPrompt")}</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" />
|
|
{t("actions.edit")}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setEditingCustomPrompt(false)
|
|
setCustomPromptDraft("")
|
|
}}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
{t("actions.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" />
|
|
{t("actions.save")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
value={editingCustomPrompt ? customPromptDraft : (config.ai_custom_prompt || "")}
|
|
onChange={e => setCustomPromptDraft(e.target.value)}
|
|
disabled={!editingCustomPrompt}
|
|
placeholder={t("settings.notifications.ai.customPromptPlaceholder")}
|
|
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" />
|
|
{t("settings.notifications.ai.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" />
|
|
{t("settings.notifications.ai.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"
|
|
>
|
|
{t("settings.notifications.ai.communityPrompts")} <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">
|
|
{t("settings.notifications.ai.customPromptHint")}
|
|
</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" /> {t("settings.notifications.ai.testing")}</>
|
|
) : (
|
|
<><Zap className="h-4 w-4" /> {t("settings.notifications.ai.testConnection")}</>
|
|
)}
|
|
</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
|
|
? t("settings.notifications.ui.activeFooter")
|
|
: t("settings.notifications.ui.inactiveFooter")}
|
|
</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">{t("settings.notifications.ai.providersInfo")}</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={providerLabel(provider)}
|
|
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">{providerLabel(provider)}</span>
|
|
</div>
|
|
{provider.value === "ollama" && (
|
|
<Badge variant="outline" className="text-xs px-2 py-0.5">{t("settings.notifications.ai.local")}</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
|
|
{t(`settings.notifications.ai.providers.${provider.value}`)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/70 mt-1 ml-[52px]">
|
|
{t("settings.notifications.ai.loadModelsHint")}
|
|
</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">{t("settings.notifications.ai.openAiCompatible")}</p>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t("settings.notifications.ai.openAiCompatibleDescription")}
|
|
</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>{t("settings.notifications.ai.anyOpenAiService")}</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">{t("settings.notifications.telegramGuide.title")}</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">{t("settings.notifications.telegramGuide.createBot")}</h4>
|
|
</div>
|
|
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
|
|
<p>{t("settings.notifications.telegramGuide.create1")} <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@BotFather</a></p>
|
|
<p>{t("settings.notifications.telegramGuide.create2")} <code className="bg-muted px-1 rounded">/newbot</code></p>
|
|
<p>{t("settings.notifications.telegramGuide.create3")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.create4")}</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">{t("settings.notifications.telegramGuide.getToken")}</h4>
|
|
</div>
|
|
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
|
|
<p>{t("settings.notifications.telegramGuide.tokenIntro")}</p>
|
|
<code className="block bg-muted px-2 py-1 rounded text-[11px] mt-1">xxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</code>
|
|
<p className="mt-1">{t("settings.notifications.telegramGuide.tokenPaste")}</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">{t("settings.notifications.telegramGuide.getChatId")}</h4>
|
|
</div>
|
|
<div className="ml-8 space-y-2 text-muted-foreground text-xs">
|
|
<p className="font-medium text-foreground/80">{t("settings.notifications.telegramGuide.optionA")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.chat1")} <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@userinfobot</a> {t("settings.notifications.telegramGuide.or")} <a href="https://t.me/getmyid_bot" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">@getmyid_bot</a></p>
|
|
<p>{t("settings.notifications.telegramGuide.chat2")}</p>
|
|
|
|
<p className="font-medium text-foreground/80 mt-2">{t("settings.notifications.telegramGuide.optionB")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.manual1")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.manual2")}</p>
|
|
<code className="block bg-muted px-2 py-1 rounded text-[11px] break-all">https://api.telegram.org/botYOUR_TOKEN/getUpdates</code>
|
|
<p>{t("settings.notifications.telegramGuide.manual3")} <code className="bg-muted px-1 rounded">"chat":{"id": XXXXXX}</code></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">{t("settings.notifications.telegramGuide.groups")}</h4>
|
|
</div>
|
|
<div className="ml-8 space-y-1 text-muted-foreground text-xs">
|
|
<p>{t("settings.notifications.telegramGuide.group1")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.group2")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.group3")}</p>
|
|
<p>{t("settings.notifications.telegramGuide.group4")} <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">{t("settings.notifications.telegramGuide.summary")}</p>
|
|
<ul className="text-xs text-muted-foreground space-y-0.5">
|
|
<li><strong>{t("settings.notifications.ui.botToken")}:</strong> {t("settings.notifications.telegramGuide.summaryToken")}</li>
|
|
<li><strong>{t("settings.notifications.ui.chatId")}:</strong> {t("settings.notifications.telegramGuide.summaryChat")}</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" />
|
|
{t("settings.notifications.customPromptInfo.title")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground">
|
|
{t("settings.notifications.customPromptInfo.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 text-sm">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-foreground/90">{t("settings.notifications.customPromptInfo.whatTitle")}</h4>
|
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
|
{t("settings.notifications.customPromptInfo.whatDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-foreground/90">{t("settings.notifications.customPromptInfo.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>{t("settings.notifications.customPromptInfo.formatIntro")}<br/>
|
|
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[TITLE]</code> {t("settings.notifications.customPromptInfo.formatTitleThen")} <code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[BODY]</code> {t("settings.notifications.customPromptInfo.formatBodyThen")}
|
|
</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-purple-400 mt-0.5">2.</span>
|
|
<span>{t("settings.notifications.customPromptInfo.plainText")}</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-purple-400 mt-0.5">3.</span>
|
|
<span>{t("settings.notifications.customPromptInfo.rawData")}</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-purple-400 mt-0.5">4.</span>
|
|
<span>{t("settings.notifications.customPromptInfo.language")}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-foreground/90">{t("settings.notifications.customPromptInfo.gettingStarted")}</h4>
|
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
|
{t("settings.notifications.customPromptInfo.gettingStartedDescription")}
|
|
</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"
|
|
>
|
|
{t("settings.notifications.customPromptInfo.loadExample")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setCustomPromptDraft("")
|
|
setEditingCustomPrompt(true)
|
|
setShowCustomPromptInfo(false)
|
|
}}
|
|
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
|
|
>
|
|
{t("settings.notifications.customPromptInfo.startFromScratch")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|