mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user