mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
- rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow) - migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale - fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `<iface>` placeholder in automated so intl no longer parses it as a tag - remove the mouse-follow blue overlay from the docs landing layout - reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile - move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile - show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent - restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate - add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel
2961 lines
147 KiB
TypeScript
2961 lines
147 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
|
|
// Pushover channel fields
|
|
user_key?: string
|
|
api_token?: string
|
|
device?: string
|
|
sound?: string
|
|
critical_priority?: boolean
|
|
// 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", "pushover", "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 },
|
|
pushover: { enabled: false, critical_priority: true },
|
|
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: {} },
|
|
pushover: { 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",
|
|
pushover: "brief",
|
|
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" },
|
|
po_user: { channel: "pushover", field: "user_key" },
|
|
po_token: { channel: "pushover", field: "api_token" },
|
|
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${
|
|
editMode
|
|
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
|
: ""
|
|
}`}
|
|
>
|
|
{/* ── 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-3 sm:grid-cols-6 h-auto">
|
|
<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="pushover" className="text-xs data-[state=active]:text-rose-500">
|
|
Pushover
|
|
</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>
|
|
|
|
{/* Pushover */}
|
|
<TabsContent value="pushover" 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.enablePushover")}</Label>
|
|
<a
|
|
href="https://pushover.net/apps/build"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-[10px] text-rose-500 hover:text-rose-400 hover:underline"
|
|
>
|
|
{t("settings.notifications.ui.setupGuide")}
|
|
<ExternalLink className="h-2.5 w-2.5" />
|
|
</a>
|
|
</div>
|
|
<button
|
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
|
config.channels.pushover?.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("pushover", "enabled", !config.channels.pushover?.enabled) }}
|
|
disabled={!editMode}
|
|
role="switch"
|
|
aria-checked={config.channels.pushover?.enabled || false}
|
|
>
|
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
config.channels.pushover?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{config.channels.pushover?.enabled && (
|
|
<>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
{t("settings.notifications.ui.pushoverCredentialsHint")}
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="space-y-1.5 min-w-0">
|
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverUserKey")}</Label>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Input
|
|
type={showSecrets["po_user"] ? "text" : "password"}
|
|
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
|
placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
|
|
value={secretValue("po_user", config.channels.pushover?.user_key || "")}
|
|
onChange={e => updateChannel("pushover", "user_key", 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("po_user")}
|
|
>
|
|
{showSecrets["po_user"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 min-w-0">
|
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverApiToken")}</Label>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Input
|
|
type={showSecrets["po_token"] ? "text" : "password"}
|
|
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
|
placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi"
|
|
value={secretValue("po_token", config.channels.pushover?.api_token || "")}
|
|
onChange={e => updateChannel("pushover", "api_token", 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("po_token")}
|
|
>
|
|
{showSecrets["po_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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.pushoverDevice")} ({t("settings.notifications.ui.optional")})</Label>
|
|
<Input
|
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
|
placeholder="iphone"
|
|
value={config.channels.pushover?.device || ""}
|
|
onChange={e => updateChannel("pushover", "device", e.target.value)}
|
|
disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.pushoverSound")} ({t("settings.notifications.ui.optional")})</Label>
|
|
<Input
|
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
|
placeholder="pushover"
|
|
value={config.channels.pushover?.sound || ""}
|
|
onChange={e => updateChannel("pushover", "sound", e.target.value)}
|
|
disabled={!editMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 py-1">
|
|
<div>
|
|
<Label className="text-xs font-medium">{t("settings.notifications.ui.pushoverCriticalPriority")}</Label>
|
|
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.pushoverCriticalPriorityDescription")}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={config.channels.pushover?.critical_priority !== 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.pushover?.critical_priority !== false ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
|
|
onClick={() => { if (editMode) updateChannel("pushover", "critical_priority", config.channels.pushover?.critical_priority === false) }}
|
|
>
|
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
config.channels.pushover?.critical_priority !== false ? "translate-x-[18px]" : "translate-x-0"
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{renderChannelCategories("pushover")}
|
|
{renderQuietHours("pushover")}
|
|
{renderDailyDigest("pushover")}
|
|
<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-rose-600 hover:bg-rose-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
|
|
onClick={() => handleTest("pushover")}
|
|
disabled={testing === "pushover" || !config.channels.pushover?.user_key || !config.channels.pushover?.api_token}
|
|
>
|
|
{testing === "pushover" ? <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://`,
|
|
`pover://` 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://appriseit.com/services/"
|
|
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">pover://</code>,
|
|
<code className="text-foreground/80 mx-0.5">mailto://</code>… {t("settings.notifications.ui.seeThe")}
|
|
{" "}
|
|
<a
|
|
href="https://appriseit.com/services/"
|
|
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>
|
|
</>
|
|
)
|
|
}
|