feat(web): notification channel settings UI
Server Deploy / deploy (push) Successful in 1m24s

This commit is contained in:
2026-07-21 14:24:38 +01:00
parent 8f3a27100f
commit df6f8b6f62
4 changed files with 254 additions and 4 deletions
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
// Config fields required per channel type.
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
discord: ["url"],
telegram: ["token", "chat_id"],
smtp: ["host", "port", "username", "password", "from", "to"],
};
function ChannelForm({ onDone }: { onDone: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [type, setType] = useState<ChannelType>("webhook");
const [config, setConfig] = useState<Record<string, string>>({});
const { mutate: create, isPending, error } = useMutation({
mutationFn: (input: ChannelInput) => api.createChannel(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
onDone();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
create({ name, type, config, enabled: true });
}}
className="space-y-4"
>
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={type}
onChange={(e) => {
setType(e.target.value as ChannelType);
setConfig({});
}}
>
{(["webhook", "smtp", "discord", "slack", "telegram"] as const).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{CONFIG_FIELDS[type].map((field) => (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={config[field] ?? ""}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
))}
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
Add Channel
</Button>
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
</form>
);
}
function ChannelRow({ ch }: { ch: NotificationChannel }) {
const queryClient = useQueryClient();
const [testMsg, setTestMsg] = useState<string | null>(null);
const { mutate: remove } = useMutation({
mutationFn: () => api.deleteChannel(ch.channel_id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testChannel(ch.channel_id),
onSuccess: () => setTestMsg("Sent!"),
onError: (e) => setTestMsg((e as Error).message),
});
const { mutate: toggle } = useMutation({
mutationFn: (enabled: boolean) => api.updateChannel(ch.channel_id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
return (
<div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-0">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{ch.name}</span>
<Badge variant="neutral">{ch.type}</Badge>
{!ch.enabled && <Badge variant="warning">disabled</Badge>}
</div>
{testMsg && <p className="mt-1 text-xs text-text-secondary">{testMsg}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
{ch.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="danger" size="sm" onClick={() => remove()}>
Delete
</Button>
</div>
</div>
);
}
export default function NotificationSettingsPage() {
const [showForm, setShowForm] = useState(false);
const { data: channels, isLoading } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<h1 className="mt-2 text-2xl font-bold text-text-primary">Notification Channels</h1>
<p className="mt-1 text-sm text-text-secondary">Alert destinations for monitor state changes.</p>
</div>
{!showForm && (
<Button variant="primary" onClick={() => setShowForm(true)}>
New Channel
</Button>
)}
</div>
{showForm && (
<Card className="mb-6 max-w-xl">
<ChannelForm onDone={() => setShowForm(false)} />
</Card>
)}
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !channels || channels.length === 0 ? (
<div className="py-16 text-center text-sm text-text-secondary">No channels configured.</div>
) : (
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
</div>
);
}