Files
alchemist/web/src/components/NotificationSettings.tsx
2026-04-07 22:13:43 -04:00

599 lines
26 KiB
TypeScript

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<string, unknown>;
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<string, unknown> {
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<NotificationTarget[]>([]);
const [dailySummaryTime, setDailySummaryTime] = useState("09:00");
const [loading, setLoading] = useState(true);
const [testingId, setTestingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [draftName, setDraftName] = useState("");
const [draftType, setDraftType] = useState<NotificationTargetType>("discord_webhook");
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>(defaultConfigForType("discord_webhook"));
const [draftEvents, setDraftEvents] = useState<string[]>(["encode.completed", "encode.failed"]);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
useEffect(() => {
void fetchTargets();
}, []);
const fetchTargets = async () => {
try {
const data = await apiJson<NotificationsSettingsResponse | LegacyNotificationTarget[]>(
"/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 (
<div className="space-y-6" aria-live="polite">
<div className="grid gap-4 md:grid-cols-[1fr_auto] items-end">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface-soft p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink">
<Bell size={16} className="text-helios-solar" />
Daily Summary Time
</div>
<p className="mt-1 text-xs text-helios-slate">
Daily summaries are opt-in per target, but they all use one global local-time send window.
</p>
<input
type="time"
value={dailySummaryTime}
onChange={(event) => 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"
/>
</div>
<button
onClick={() => void saveDailySummaryTime()}
className="rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink hover:bg-helios-surface-soft transition-colors"
>
Save Summary Time
</button>
</div>
<div className="flex justify-end">
<button
onClick={() => setShowForm((current) => !current)}
className="flex items-center gap-2 px-3 py-1.5 bg-helios-surface border border-helios-line/30 hover:bg-helios-surface-soft text-helios-ink rounded-lg text-xs font-medium transition-colors"
>
<Plus size={14} />
{showForm ? "Cancel" : "Add Target"}
</button>
</div>
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
{showForm && (
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-helios-slate mb-1">Name</label>
<input
value={draftName}
onChange={(event) => 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
/>
</div>
<div>
<label className="block text-xs font-medium text-helios-slate mb-1">Type</label>
<select
value={draftType}
onChange={(event) => {
const nextType = event.target.value as NotificationTargetType;
setDraftType(nextType);
setDraftConfig(defaultConfigForType(nextType));
}}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
>
{TARGET_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
</div>
{draftType === "discord_webhook" && (
<TextField
label="Webhook URL"
value={String(draftConfig.webhook_url ?? "")}
onChange={(value) => setConfigField("webhook_url", value)}
placeholder="https://discord.com/api/webhooks/..."
/>
)}
{draftType === "discord_bot" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TextField
label="Bot Token"
value={String(draftConfig.bot_token ?? "")}
onChange={(value) => setConfigField("bot_token", value)}
placeholder="Discord bot token"
/>
<TextField
label="Channel ID"
value={String(draftConfig.channel_id ?? "")}
onChange={(value) => setConfigField("channel_id", value)}
placeholder="123456789012345678"
/>
</div>
)}
{draftType === "gotify" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TextField
label="Server URL"
value={String(draftConfig.server_url ?? "")}
onChange={(value) => setConfigField("server_url", value)}
placeholder="https://gotify.example.com/message"
/>
<TextField
label="App Token"
value={String(draftConfig.app_token ?? "")}
onChange={(value) => setConfigField("app_token", value)}
placeholder="Gotify app token"
/>
</div>
)}
{draftType === "webhook" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TextField
label="Endpoint URL"
value={String(draftConfig.url ?? "")}
onChange={(value) => setConfigField("url", value)}
placeholder="https://example.com/webhook"
/>
<TextField
label="Bearer Token (Optional)"
value={String(draftConfig.auth_token ?? "")}
onChange={(value) => setConfigField("auth_token", value)}
placeholder="Bearer token"
/>
</div>
)}
{draftType === "telegram" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TextField
label="Bot Token"
value={String(draftConfig.bot_token ?? "")}
onChange={(value) => setConfigField("bot_token", value)}
placeholder="Telegram bot token"
/>
<TextField
label="Chat ID"
value={String(draftConfig.chat_id ?? "")}
onChange={(value) => setConfigField("chat_id", value)}
placeholder="Telegram chat ID"
/>
</div>
)}
{draftType === "email" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TextField
label="SMTP Host"
value={String(draftConfig.smtp_host ?? "")}
onChange={(value) => setConfigField("smtp_host", value)}
placeholder="smtp.example.com"
/>
<TextField
label="SMTP Port"
value={String(draftConfig.smtp_port ?? 587)}
onChange={(value) => setConfigField("smtp_port", Number(value))}
placeholder="587"
/>
<TextField
label="Username"
value={String(draftConfig.username ?? "")}
onChange={(value) => setConfigField("username", value)}
placeholder="Optional"
/>
<TextField
label="Password"
value={String(draftConfig.password ?? "")}
onChange={(value) => setConfigField("password", value)}
placeholder="Optional"
/>
<TextField
label="From Address"
value={String(draftConfig.from_address ?? "")}
onChange={(value) => setConfigField("from_address", value)}
placeholder="alchemist@example.com"
/>
<TextField
label="To Addresses"
value={Array.isArray(draftConfig.to_addresses) ? String((draftConfig.to_addresses as string[]).join(", ")) : ""}
onChange={(value) =>
setConfigField(
"to_addresses",
value
.split(",")
.map((candidate) => candidate.trim())
.filter(Boolean),
)
}
placeholder="ops@example.com, alerts@example.com"
/>
<div>
<label className="block text-xs font-medium text-helios-slate mb-1">Security</label>
<select
value={String(draftConfig.security ?? "starttls")}
onChange={(event) => setConfigField("security", event.target.value)}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
>
<option value="starttls">STARTTLS</option>
<option value="tls">TLS / SMTPS</option>
<option value="none">None</option>
</select>
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-helios-slate mb-2">Events</label>
<div className="flex gap-2 flex-wrap">
{EVENT_OPTIONS.map((evt) => (
<button
key={evt}
type="button"
onClick={() => toggleEvent(evt)}
className={`rounded-full border px-3 py-2 text-xs font-semibold transition-all ${
draftEvents.includes(evt)
? "border-helios-solar bg-helios-solar/10 text-helios-ink"
: "border-helios-line/20 text-helios-slate"
}`}
>
{evt}
</button>
))}
</div>
</div>
<button type="submit" className="w-full bg-helios-solar text-helios-main font-bold py-2 rounded-lg hover:opacity-90 transition-opacity">
Save Target
</button>
</form>
)}
{loading ? (
<div className="text-sm text-helios-slate animate-pulse">Loading targets</div>
) : (
<div className="space-y-3">
{targets.map((target) => (
<div key={target.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl group/item">
<div className="flex items-center gap-4">
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
<Bell size={18} />
</div>
<div className="min-w-0">
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs font-medium text-helios-slate bg-helios-surface-soft px-1.5 rounded">
{target.target_type}
</span>
<span className="text-xs text-helios-slate break-all">
{targetSummary(target)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{target.events.map((eventName) => (
<span key={eventName} className="rounded-full border border-helios-line/20 px-2 py-0.5 text-[11px] text-helios-slate">
{eventName}
</span>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleTest(target)}
disabled={testingId === target.id}
className="p-2 text-helios-slate hover:text-helios-solar hover:bg-helios-solar/10 rounded-lg transition-colors"
title="Test Notification"
>
<Zap size={16} className={testingId === target.id ? "animate-pulse" : ""} />
</button>
<button
onClick={() => setPendingDeleteId(target.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
aria-label={`Delete notification target ${target.name}`}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
<ConfirmDialog
open={pendingDeleteId !== null}
title="Remove notification target"
description="Remove this notification target?"
confirmLabel="Remove"
tone="danger"
onClose={() => setPendingDeleteId(null)}
onConfirm={async () => {
if (pendingDeleteId === null) return;
await handleDelete(pendingDeleteId);
}}
/>
</div>
);
}
function TextField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<div>
<label className="block text-xs font-medium text-helios-slate mb-1">{label}</label>
<input
value={value}
onChange={(event) => 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}
/>
</div>
);
}