merge(notifications): generic webhook + SMTP email channels and operator delivery health

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:47:16 +02:00
commit 45ccdcb305
35 files changed
+2064 -13

No files matched your search

+107 -3
View File
@@ -1,8 +1,15 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
HeartPulse,
Loader2,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -10,8 +17,102 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
interface NotificationHealth {
windowHours: number;
since: string;
channels: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}>;
autoDisabledChannels: Array<{ type: string; count: number }>;
}
/**
* v1.17.1 — operator-wide notification delivery-health panel. A single
* `groupBy(channel, result)` over the trailing 24h of `push_attempts` plus the
* count of auto-disabled channels across all users. Lets the operator answer
* "are anyone's pushes failing?" without DB shell.
*/
function NotificationHealthPanel() {
const { t } = useTranslations();
const windowHours = 24;
const { data, isLoading } = useQuery({
queryKey: queryKeys.adminNotificationHealth(windowHours),
queryFn: async () =>
apiGet<NotificationHealth>(
`/api/admin/notifications/health?hours=${windowHours}`,
),
});
return (
<div className="bg-card border-border mt-6 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
description={t("admin.notificationHealth.description")}
/>
<div className="mt-4 pl-7">
{isLoading && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{data && data.channels.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.notificationHealth.empty")}
</p>
)}
{data && data.channels.length > 0 && (
<div className="space-y-2">
{data.channels.map((c) => (
<div
key={c.channel}
className="flex items-center justify-between text-sm"
>
<span className="font-medium">{c.channel}</span>
<span className="flex items-center gap-3 text-xs">
<span className="text-success dark:text-green-400">
{t("admin.notificationHealth.ok")}: {c.ok}
</span>
<span className="text-destructive dark:text-red-400">
{t("admin.notificationHealth.error")}: {c.error}
</span>
<span className="text-muted-foreground">
{t("admin.notificationHealth.skipped")}: {c.skipped}
</span>
</span>
</div>
))}
</div>
)}
{data && data.autoDisabledChannels.length > 0 && (
<div className="mt-4 space-y-1">
<h2 className="text-sm font-medium">
{t("admin.notificationHealth.autoDisabled")}
</h2>
<div className="flex flex-wrap gap-2">
{data.autoDisabledChannels.map((d) => (
<Badge key={d.type} variant="secondary">
{d.type} ({d.count})
</Badge>
))}
</div>
</div>
)}
</div>
</div>
);
}
export function RemindersSection() {
const { t } = useTranslations();
@@ -92,6 +193,7 @@ export function RemindersSection() {
});
return (
<>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
@@ -295,5 +397,7 @@ export function RemindersSection() {
</div>
)}
</div>
<NotificationHealthPanel />
</>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface EmailSettings {
enabled: boolean;
recipient: string;
/** Operator-side: false when SMTP_* env is unset (card hides itself). */
smtpConfigured: boolean;
}
export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [recipient, setRecipient] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
queryFn: async () => {
return apiGet<EmailSettings>("/api/settings/email");
},
enabled: isAuthenticated,
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings ? settings.recipient : null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey !== null && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setRecipient(settings!.recipient);
}
const save = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiFetchRaw("/api/settings/email", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipient, enabled }),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
// Operator hasn't configured SMTP — no transport, so no card. The dispatcher
// skips the channel too; surfacing an unusable toggle would only confuse.
if (settings && !settings.smtpConfigured) return null;
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Mail}
title={t("settings.email")}
description={t("settings.emailDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="email-recipient">
{t("settings.emailRecipient")}
</Label>
<Input
id="email-recipient"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -14,6 +14,8 @@ import { NotificationStatusCard } from "@/components/settings/notification-statu
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
import { WebPushCard } from "@/components/settings/web-push-card";
import { WebhookCard } from "@/components/settings/webhook-card";
import { EmailCard } from "@/components/settings/email-card";
import { apiGet } from "@/lib/api/api-fetch";
interface GlobalServiceAvailability {
@@ -105,6 +107,16 @@ export function NotificationsSection() {
<WebPushCard />
</div>
)}
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
Matrix / Home Assistant in one channel). */}
<div id="webhook" className="scroll-mt-28">
<WebhookCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
<div id="email" className="scroll-mt-28">
<EmailCard isAuthenticated={isAuthenticated} />
</div>
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface WebhookSettings {
enabled: boolean;
url: string;
headerName: string;
hasHeaderValue: boolean;
}
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
queryFn: async () => {
return apiGet<WebhookSettings>("/api/settings/webhook");
},
enabled: isAuthenticated,
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings ? `${settings.url}|${settings.headerName}` : null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setUrl(settings!.url);
setHeaderName(settings!.headerName);
}
const save = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiFetchRaw("/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
headerName: headerName || undefined,
headerValue: headerValue || undefined,
enabled,
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setHeaderValue("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Webhook}
title={t("settings.webhook")}
description={t("settings.webhookDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
<Input
id="webhook-url"
placeholder="https://gotify.example.com/message?token=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
</Label>
<Input
id="webhook-header-name"
placeholder="Authorization"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-value">
{t("settings.webhookHeaderValue")}
</Label>
<PasswordInput
id="webhook-header-value"
placeholder={
settings?.hasHeaderValue
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
/>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}