import { useEffect, useState } from "react"; import { Bell, Plus, Trash2, Zap } from "lucide-react"; import { apiAction, apiJson, isApiError } from "../lib/api"; import { showToast } from "../lib/toast"; import ConfirmDialog from "./ui/ConfirmDialog"; type NotificationTargetType = | "discord_webhook" | "discord_bot" | "gotify" | "webhook" | "telegram" | "email"; interface NotificationTarget { id: number; name: string; target_type: NotificationTargetType; config_json: Record; events: string[]; enabled: boolean; created_at: string; } interface NotificationsSettingsResponse { daily_summary_time_local: string; targets: NotificationTarget[]; } interface LegacyNotificationTarget { id: number; name: string; target_type: "discord" | "gotify" | "webhook"; endpoint_url: string; auth_token: string | null; events: string; enabled: boolean; created_at?: string; } const TARGET_TYPES: Array<{ value: NotificationTargetType; label: string }> = [ { value: "discord_webhook", label: "Discord Webhook" }, { value: "discord_bot", label: "Discord Bot" }, { value: "gotify", label: "Gotify" }, { value: "webhook", label: "Generic Webhook" }, { value: "telegram", label: "Telegram" }, { value: "email", label: "Email" }, ]; const EVENT_OPTIONS = [ "encode.queued", "encode.started", "encode.completed", "encode.failed", "scan.completed", "engine.idle", "daily.summary", ]; function targetSummary(target: NotificationTarget): string { const config = target.config_json; switch (target.target_type) { case "discord_webhook": return String(config.webhook_url ?? ""); case "discord_bot": return `channel ${String(config.channel_id ?? "")}`; case "gotify": return String(config.server_url ?? ""); case "webhook": return String(config.url ?? ""); case "telegram": return `chat ${String(config.chat_id ?? "")}`; case "email": return String((config.to_addresses as string[] | undefined)?.join(", ") ?? ""); default: return ""; } } function normalizeTarget(target: NotificationTarget | LegacyNotificationTarget): NotificationTarget { if ("config_json" in target) { return target; } const normalizedType: NotificationTargetType = target.target_type === "discord" ? "discord_webhook" : target.target_type; const config_json = normalizedType === "discord_webhook" ? { webhook_url: target.endpoint_url } : normalizedType === "gotify" ? { server_url: target.endpoint_url, app_token: target.auth_token ?? "" } : { url: target.endpoint_url, auth_token: target.auth_token ?? "" }; return { id: target.id, name: target.name, target_type: normalizedType, config_json, events: JSON.parse(target.events), enabled: target.enabled, created_at: target.created_at ?? new Date().toISOString(), }; } function defaultConfigForType(type: NotificationTargetType): Record { switch (type) { case "discord_webhook": return { webhook_url: "" }; case "discord_bot": return { bot_token: "", channel_id: "" }; case "gotify": return { server_url: "", app_token: "" }; case "webhook": return { url: "", auth_token: "" }; case "telegram": return { bot_token: "", chat_id: "" }; case "email": return { smtp_host: "", smtp_port: 587, username: "", password: "", from_address: "", to_addresses: [""], security: "starttls", }; } } export default function NotificationSettings() { const [targets, setTargets] = useState([]); const [dailySummaryTime, setDailySummaryTime] = useState("09:00"); const [loading, setLoading] = useState(true); const [testingId, setTestingId] = useState(null); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [draftName, setDraftName] = useState(""); const [draftType, setDraftType] = useState("discord_webhook"); const [draftConfig, setDraftConfig] = useState>(defaultConfigForType("discord_webhook")); const [draftEvents, setDraftEvents] = useState(["encode.completed", "encode.failed"]); const [pendingDeleteId, setPendingDeleteId] = useState(null); useEffect(() => { void fetchTargets(); }, []); const fetchTargets = async () => { try { const data = await apiJson( "/api/settings/notifications", ); if (Array.isArray(data)) { setTargets(data.map(normalizeTarget)); setDailySummaryTime("09:00"); } else { setTargets(data.targets.map(normalizeTarget)); setDailySummaryTime(data.daily_summary_time_local); } setError(null); } catch (e) { const message = isApiError(e) ? e.message : "Failed to load notification targets"; setError(message); } finally { setLoading(false); } }; const saveDailySummaryTime = async () => { try { await apiAction("/api/settings/notifications", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ daily_summary_time_local: dailySummaryTime }), }); showToast({ kind: "success", title: "Notifications", message: "Daily summary time saved.", }); } catch (e) { const message = isApiError(e) ? e.message : "Failed to save daily summary time"; setError(message); showToast({ kind: "error", title: "Notifications", message }); } }; const resetDraft = (type: NotificationTargetType = "discord_webhook") => { setDraftName(""); setDraftType(type); setDraftConfig(defaultConfigForType(type)); setDraftEvents(["encode.completed", "encode.failed"]); }; const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); try { await apiAction("/api/settings/notifications", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: draftName, target_type: draftType, config_json: draftConfig, events: draftEvents, enabled: true, }), }); setShowForm(false); resetDraft(); setError(null); await fetchTargets(); showToast({ kind: "success", title: "Notifications", message: "Target added." }); } catch (e) { const message = isApiError(e) ? e.message : "Failed to add notification target"; setError(message); showToast({ kind: "error", title: "Notifications", message }); } }; const handleDelete = async (id: number) => { try { await apiAction(`/api/settings/notifications/${id}`, { method: "DELETE" }); setError(null); await fetchTargets(); showToast({ kind: "success", title: "Notifications", message: "Target removed." }); } catch (e) { const message = isApiError(e) ? e.message : "Failed to remove target"; setError(message); showToast({ kind: "error", title: "Notifications", message }); } }; const handleTest = async (target: NotificationTarget) => { setTestingId(target.id); try { await apiAction("/api/settings/notifications/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: target.name, target_type: target.target_type, config_json: target.config_json, events: target.events, enabled: target.enabled, }), }); showToast({ kind: "success", title: "Notifications", message: "Test notification sent." }); } catch (e) { const message = isApiError(e) ? e.message : "Test notification failed"; setError(message); showToast({ kind: "error", title: "Notifications", message }); } finally { setTestingId(null); } }; const toggleEvent = (evt: string) => { setDraftEvents((current) => current.includes(evt) ? current.filter((candidate) => candidate !== evt) : [...current, evt], ); }; const setConfigField = (key: string, value: unknown) => { setDraftConfig((current) => ({ ...current, [key]: value })); }; return (
Daily Summary Time

Daily summaries are opt-in per target, but they all use one global local-time send window.

setDailySummaryTime(event.target.value)} className="mt-3 w-full max-w-xs bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink" />
{error && (
{error}
)} {showForm && (
setDraftName(event.target.value)} className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink" placeholder="My Discord" required />
{draftType === "discord_webhook" && ( setConfigField("webhook_url", value)} placeholder="https://discord.com/api/webhooks/..." /> )} {draftType === "discord_bot" && (
setConfigField("bot_token", value)} placeholder="Discord bot token" /> setConfigField("channel_id", value)} placeholder="123456789012345678" />
)} {draftType === "gotify" && (
setConfigField("server_url", value)} placeholder="https://gotify.example.com/message" /> setConfigField("app_token", value)} placeholder="Gotify app token" />
)} {draftType === "webhook" && (
setConfigField("url", value)} placeholder="https://example.com/webhook" /> setConfigField("auth_token", value)} placeholder="Bearer token" />
)} {draftType === "telegram" && (
setConfigField("bot_token", value)} placeholder="Telegram bot token" /> setConfigField("chat_id", value)} placeholder="Telegram chat ID" />
)} {draftType === "email" && (
setConfigField("smtp_host", value)} placeholder="smtp.example.com" /> setConfigField("smtp_port", Number(value))} placeholder="587" /> setConfigField("username", value)} placeholder="Optional" /> setConfigField("password", value)} placeholder="Optional" /> setConfigField("from_address", value)} placeholder="alchemist@example.com" /> setConfigField( "to_addresses", value .split(",") .map((candidate) => candidate.trim()) .filter(Boolean), ) } placeholder="ops@example.com, alerts@example.com" />
)}
{EVENT_OPTIONS.map((evt) => ( ))}
)} {loading ? (
Loading targets…
) : (
{targets.map((target) => (

{target.name}

{target.target_type} {targetSummary(target)}
{target.events.map((eventName) => ( {eventName} ))}
))}
)} setPendingDeleteId(null)} onConfirm={async () => { if (pendingDeleteId === null) return; await handleDelete(pendingDeleteId); }} />
); } function TextField({ label, value, onChange, placeholder, }: { label: string; value: string; onChange: (value: string) => void; placeholder: string; }) { return (
onChange(event.target.value)} className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink" placeholder={placeholder} />
); }