mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(admin): status-first card grid + aggregator endpoint (#133)
Replaces the implicit, fluid status of the legacy admin monolith with a 6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card pairs a §2.6 colored dot with a text label so state never relies on color alone (WCAG 1.4.1). - New aggregator endpoint /api/admin/status-overview returns a single batched Promise.all (no N+1) covering Users, Integrations, Monitoring, Backups, Maintenance, Audit log — with severity precomputed server-side. - New <StatusCardGrid> + reusable <StatusBadge> component in src/components/admin/. Pure buildCards() function makes severity mapping unit-testable. - Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y). - Wires the grid above the existing admin sections; per-section extraction will land in follow-ups. Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
This commit is contained in:
1 parent
f4a2e13985
commit
725cace865
5 files changed
+767
No files matched your search
@@ -62,6 +62,7 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { toast } from "sonner";
|
||||
import { StatusCardGrid } from "@/components/admin/status-card-grid";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -162,6 +163,8 @@ export default function AdminPage() {
|
||||
<p className="text-muted-foreground text-sm">{t("admin.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<StatusCardGrid />
|
||||
|
||||
<div className="space-y-6">
|
||||
<SystemStatusSection id="section-system-status" />
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.all (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// Single batched roundtrip — no N+1.
|
||||
const [
|
||||
userTotal,
|
||||
userAdmins,
|
||||
userNewThisWeek,
|
||||
withingsCount,
|
||||
moodLogCount,
|
||||
telegramCount,
|
||||
ntfyCount,
|
||||
webPushCount,
|
||||
appSettings,
|
||||
lastErrorEntry,
|
||||
latestBackup,
|
||||
backedUpUsers,
|
||||
last30dEvents,
|
||||
lastLoginEntry,
|
||||
lastIdempotencyCleanup,
|
||||
lastAuditLogCleanup,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { role: "ADMIN" } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.withingsConnection.count(),
|
||||
prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
prisma.pushSubscription.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { StatusBadge, buildCards } from "../status-card-grid";
|
||||
import type { StatusOverview } from "@/app/api/admin/status-overview/route";
|
||||
|
||||
const mockOverview: StatusOverview = {
|
||||
users: { severity: "good", total: 3, admins: 1, newThisWeek: 1 },
|
||||
integrations: {
|
||||
severity: "good",
|
||||
withings: 1,
|
||||
moodLog: 0,
|
||||
telegram: 1,
|
||||
ntfy: 0,
|
||||
webPush: 2,
|
||||
},
|
||||
monitoring: {
|
||||
severity: "caution",
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: false,
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: null,
|
||||
},
|
||||
backups: {
|
||||
severity: "alert",
|
||||
lastBackupAt: null,
|
||||
backedUpUsers: 0,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: "good",
|
||||
workerRunning: true,
|
||||
workerUptimeSeconds: 3600,
|
||||
lastIdempotencyCleanup: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastAuditLogCleanup: null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: 42,
|
||||
lastLoginAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
describe("<StatusBadge>", () => {
|
||||
it("renders a colored dot plus a text label (never color-only)", () => {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity="good" />);
|
||||
// dot must be aria-hidden so SR users don't hear it
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// label must be present so color-blind users can read state
|
||||
expect(html).toContain("Healthy");
|
||||
// wrapper carries the announcement
|
||||
expect(html).toContain('aria-label="Status: Healthy"');
|
||||
});
|
||||
|
||||
it("maps severity to the §2.6 dracula color tokens", () => {
|
||||
const cases: Array<
|
||||
[Parameters<typeof StatusBadge>[0]["severity"], string]
|
||||
> = [
|
||||
["good", "var(--dracula-green)"],
|
||||
["info", "var(--dracula-cyan)"],
|
||||
["caution", "var(--dracula-orange)"],
|
||||
["alert", "var(--dracula-red)"],
|
||||
["pending", "var(--muted-foreground)"],
|
||||
];
|
||||
for (const [severity, token] of cases) {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity={severity} />);
|
||||
expect(html).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects an explicit label override", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusBadge severity="alert" label="Disk full" />,
|
||||
);
|
||||
expect(html).toContain("Disk full");
|
||||
expect(html).toContain('aria-label="Status: Disk full"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCards()", () => {
|
||||
it("returns exactly 6 cards in the §3.4 admin order", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards).toHaveLength(6);
|
||||
expect(cards.map((c) => c.title)).toEqual([
|
||||
"Users",
|
||||
"Integrations",
|
||||
"Monitoring",
|
||||
"Backups",
|
||||
"Maintenance",
|
||||
"Audit log",
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates severity from the aggregator response", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards[0].severity).toBe("good"); // users
|
||||
expect(cards[2].severity).toBe("caution"); // monitoring
|
||||
expect(cards[3].severity).toBe("alert"); // backups (no backup yet)
|
||||
expect(cards[5].severity).toBe("info"); // audit log
|
||||
});
|
||||
|
||||
it("links each card to a manageable destination", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.href.startsWith("/admin")).toBe(true);
|
||||
expect(card.cta.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes 3 metric tuples per card", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.metrics).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders worker status as plain text, not color alone", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
const maintenance = cards.find((c) => c.title === "Maintenance");
|
||||
expect(maintenance?.metrics[0].value).toBe("Running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Users,
|
||||
Plug,
|
||||
Activity,
|
||||
Database,
|
||||
Wrench,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
StatusOverview,
|
||||
StatusSeverity,
|
||||
} from "@/app/api/admin/status-overview/route";
|
||||
|
||||
/**
|
||||
* §2.6 status taxonomy. Renders a colored dot AND a label so we never
|
||||
* lean on color alone (WCAG 1.4.1).
|
||||
*/
|
||||
const SEVERITY_LABEL: Record<StatusSeverity, string> = {
|
||||
good: "Healthy",
|
||||
info: "Up to date",
|
||||
caution: "Attention",
|
||||
alert: "Action required",
|
||||
pending: "Loading",
|
||||
};
|
||||
|
||||
const SEVERITY_COLOR: Record<StatusSeverity, string> = {
|
||||
good: "var(--dracula-green)",
|
||||
info: "var(--dracula-cyan)",
|
||||
caution: "var(--dracula-orange)",
|
||||
alert: "var(--dracula-red)",
|
||||
pending: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
severity,
|
||||
label,
|
||||
}: {
|
||||
severity: StatusSeverity;
|
||||
label?: string;
|
||||
}) {
|
||||
const text = label ?? SEVERITY_LABEL[severity];
|
||||
return (
|
||||
<span
|
||||
className="bg-muted/40 inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
aria-label={`Status: ${text}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: SEVERITY_COLOR[severity] }}
|
||||
/>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
severity: StatusSeverity;
|
||||
metrics: Array<{ label: string; value: string | number }>;
|
||||
href: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
title,
|
||||
icon,
|
||||
severity,
|
||||
metrics,
|
||||
href,
|
||||
cta,
|
||||
}: StatusCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<StatusBadge severity={severity} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-3 gap-2">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="flex flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{m.label}</dt>
|
||||
<dd className="font-mono text-sm tabular-nums">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Link
|
||||
href={href}
|
||||
className="text-primary inline-flex items-center gap-1 self-start text-sm hover:underline"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.round(ms / 60_000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
function fmtUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "—";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86_400)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 6 admin status cards from the aggregator response. Pure;
|
||||
* no data fetching here — easy to unit test.
|
||||
*/
|
||||
export function buildCards(data: StatusOverview): StatusCardProps[] {
|
||||
return [
|
||||
{
|
||||
title: "Users",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
severity: data.users.severity,
|
||||
metrics: [
|
||||
{ label: "Total", value: data.users.total },
|
||||
{ label: "Admins", value: data.users.admins },
|
||||
{ label: "New 7d", value: data.users.newThisWeek },
|
||||
],
|
||||
href: "/admin/users",
|
||||
cta: "Manage users",
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
icon: <Plug className="h-4 w-4" />,
|
||||
severity: data.integrations.severity,
|
||||
metrics: [
|
||||
{ label: "Withings", value: data.integrations.withings },
|
||||
{ label: "moodLog", value: data.integrations.moodLog },
|
||||
{ label: "Push", value: data.integrations.webPush },
|
||||
],
|
||||
href: "/admin#integrations",
|
||||
cta: "Configure",
|
||||
},
|
||||
{
|
||||
title: "Monitoring",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
severity: data.monitoring.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "GlitchTip",
|
||||
value: data.monitoring.glitchtipEnabled ? "On" : "Off",
|
||||
},
|
||||
{
|
||||
label: "Umami",
|
||||
value: data.monitoring.umamiEnabled ? "On" : "Off",
|
||||
},
|
||||
{ label: "Last err", value: fmtRelative(data.monitoring.lastErrorAt) },
|
||||
],
|
||||
href: "/admin#monitoring",
|
||||
cta: "View monitoring",
|
||||
},
|
||||
{
|
||||
title: "Backups",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
severity: data.backups.severity,
|
||||
metrics: [
|
||||
{ label: "Last", value: fmtRelative(data.backups.lastBackupAt) },
|
||||
{ label: "Users", value: data.backups.backedUpUsers },
|
||||
{ label: "Retain", value: `${data.backups.retentionDays}d` },
|
||||
],
|
||||
href: "/admin#backups",
|
||||
cta: "View backups",
|
||||
},
|
||||
{
|
||||
title: "Maintenance",
|
||||
icon: <Wrench className="h-4 w-4" />,
|
||||
severity: data.maintenance.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "Worker",
|
||||
value: data.maintenance.workerRunning ? "Running" : "Stopped",
|
||||
},
|
||||
{
|
||||
label: "Uptime",
|
||||
value: fmtUptime(data.maintenance.workerUptimeSeconds),
|
||||
},
|
||||
{
|
||||
label: "Cleanup",
|
||||
value: fmtRelative(data.maintenance.lastIdempotencyCleanup),
|
||||
},
|
||||
],
|
||||
href: "/admin#maintenance",
|
||||
cta: "View jobs",
|
||||
},
|
||||
{
|
||||
title: "Audit log",
|
||||
icon: <ScrollText className="h-4 w-4" />,
|
||||
severity: data.auditLog.severity,
|
||||
metrics: [
|
||||
{ label: "Events 30d", value: data.auditLog.eventsLast30d },
|
||||
{ label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) },
|
||||
{ label: "—", value: "" },
|
||||
],
|
||||
href: "/admin/audit-log",
|
||||
cta: "Open viewer",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function StatusCardGrid() {
|
||||
const { data, isLoading } = useQuery<StatusOverview>({
|
||||
queryKey: ["admin", "status-overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status-overview");
|
||||
if (!res.ok) throw new Error("Failed to load admin status overview");
|
||||
const json = (await res.json()) as { data: StatusOverview };
|
||||
return json.data;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = buildCards(data);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<StatusCard key={c.title} {...c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user