mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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);
|
||||
});
|
||||
Reference in new issue
Block a user