feat(admin): operator-wide notification health panel

Surface the webhook + email cards on the notifications settings page, and
add an operator-wide delivery-health view the per-self diagnostic could
not give. A single groupBy(channel, result) over the trailing 24h of
push_attempts lets the operator answer "are anyone's pushes failing?"
across all users without DB shell, and a count of auto-disabled channels
(enabled=false with an automated disabledReason) flags a flapping upstream
that silently disabled a channel after five failures. The aggregate is
anonymous — it counts channel x result, never names a user. Admin-only by
construction (cookie auth, never Bearer); the window is clamped 1..168h.
The panel rides the existing admin Reminders section.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:46:21 +02:00
1 parent 909c2aa99e
commit 17dd10c3b9
5 files changed
+380 -3

No files matched your search

@@ -0,0 +1,155 @@
/**
* v1.17.1 — coverage for the operator-wide notification-health admin route.
*
* Pins:
* * 401 unauthenticated / 403 non-admin (requireAdmin is cookie-only),
* * the groupBy(channel, result) aggregate folds into per-channel
* ok/error/skipped/total rows,
* * auto-disabled channels (enabled=false + disabledReason not null) are
* surfaced with a count,
* * the window is clamped to 1..168 hours (default 24).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: { groupBy: vi.fn() },
notificationChannel: { groupBy: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return { ...actual, requireAdmin: vi.fn() };
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { run: <T>(_e: unknown, fn: () => Promise<T>) => fn() },
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { NextRequest } from "next/server";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" },
} as never;
function req(hours?: string): NextRequest {
const url = hours
? `http://localhost/api/admin/notifications/health?hours=${hours}`
: "http://localhost/api/admin/notifications/health";
return new NextRequest(url);
}
interface HealthEnvelope {
data: {
windowHours: number;
channels: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}>;
autoDisabledChannels: Array<{ type: string; count: number }>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/health — auth gates", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET(req());
expect(res.status).toBe(401);
});
it("returns 403 for a non-admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET(req());
expect(res.status).toBe(403);
});
});
describe("GET /api/admin/notifications/health — aggregate", () => {
beforeEach(() => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
it("folds groupBy rows into per-channel ok/error/skipped/total", async () => {
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([
{ channel: "NTFY", result: "ok", _count: { _all: 40 } },
{ channel: "NTFY", result: "error", _count: { _all: 12 } },
{ channel: "EMAIL", result: "ok", _count: { _all: 5 } },
{ channel: "EMAIL", result: "skipped", _count: { _all: 2 } },
] as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as HealthEnvelope;
const ntfy = body.data.channels.find((c) => c.channel === "NTFY");
expect(ntfy).toEqual({
channel: "NTFY",
ok: 40,
error: 12,
skipped: 0,
total: 52,
});
const email = body.data.channels.find((c) => c.channel === "EMAIL");
expect(email).toEqual({
channel: "EMAIL",
ok: 5,
error: 0,
skipped: 2,
total: 7,
});
// Sorted alphabetically.
expect(body.data.channels.map((c) => c.channel)).toEqual(["EMAIL", "NTFY"]);
});
it("surfaces auto-disabled channels with a count", async () => {
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([
{ type: "WEBHOOK", _count: { _all: 3 } },
] as never);
const res = await GET(req());
const body = (await res.json()) as HealthEnvelope;
expect(body.data.autoDisabledChannels).toEqual([
{ type: "WEBHOOK", count: 3 },
]);
});
it("defaults the window to 24h and clamps out-of-range values", async () => {
let res = await GET(req("999"));
let body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("0"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("48"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(48);
});
});
@@ -0,0 +1,103 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
export const dynamic = "force-dynamic";
/**
* v1.17.1 — operator-wide notification delivery health.
*
* The per-self diagnostic (`/api/admin/notifications/diagnostic`) only shows
* the calling admin's own account. On a multi-user instance the operator
* cannot answer "are anyone's pushes failing?" without DB shell. This route
* does a fleet-wide `groupBy(channel, result)` over the trailing window of the
* `push_attempts` ledger, plus a list of channels currently auto-disabled
* (across ALL users), so the operator can spot a flapping upstream that
* silently disabled a channel after five failures.
*
* Admin-only (cookie auth, never Bearer) by construction via `requireAdmin()`.
* The aggregate is anonymous: it counts channel×result, it never names a user.
*/
interface ChannelHealthRow {
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}
interface DisabledChannelRow {
type: string;
count: number;
}
interface HealthPayload {
windowHours: number;
since: string;
channels: ChannelHealthRow[];
autoDisabledChannels: DisabledChannelRow[];
}
export const GET = apiHandler(async (request) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.health" } });
// Window is operator-tunable via ?hours=, clamped 1..168 (7 days). The
// `(channel, result, created_at)`-friendly index on push_attempts keeps the
// groupBy bounded regardless of instance lifetime volume.
const url = new URL(request.url);
const hoursRaw = Number(url.searchParams.get("hours"));
const windowHours =
Number.isFinite(hoursRaw) && hoursRaw >= 1 && hoursRaw <= 168
? Math.floor(hoursRaw)
: 24;
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000);
const [grouped, disabled] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
where: { createdAt: { gte: since } },
_count: { _all: true },
}),
// Channels disabled with an automated reason. A manual disable leaves
// `disabledReason` NULL (see schema comment), so a non-null reason on a
// disabled channel is the auto-disable signal we surface to the operator.
prisma.notificationChannel.groupBy({
by: ["type"],
where: { enabled: false, disabledReason: { not: null } },
_count: { _all: true },
}),
]);
const byChannel = new Map<string, ChannelHealthRow>();
for (const row of grouped) {
const existing =
byChannel.get(row.channel) ??
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
const count = row._count._all;
if (row.result === "ok") existing.ok += count;
else if (row.result === "error") existing.error += count;
else if (row.result === "skipped") existing.skipped += count;
existing.total += count;
byChannel.set(row.channel, existing);
}
const channels = Array.from(byChannel.values()).sort((a, b) =>
a.channel.localeCompare(b.channel),
);
const autoDisabledChannels: DisabledChannelRow[] = disabled
.map((d) => ({ type: d.type, count: d._count._all }))
.sort((a, b) => a.type.localeCompare(b.type));
const payload: HealthPayload = {
windowHours,
since: since.toISOString(),
channels,
autoDisabledChannels,
};
return apiSuccess(payload);
});
+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 />
</>
);
}
@@ -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>
+3
View File
@@ -63,6 +63,9 @@ export const adminKeys = {
adminSettings: () => ["admin", "settings"] as const,
adminStatus: () => ["admin", "status"] as const,
/** v1.17.1 — operator-wide notification delivery-health panel. */
adminNotificationHealth: (hours: number) =>
["admin", "notification-health", hours] as const,
adminUsers: () => ["admin", "users"] as const,
adminTokens: () => ["admin", "tokens"] as const,
adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,