mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(admin): replace overview section-grid with audit-log preview + system snapshot
The `/admin` overview rendered a six-card status grid plus a quick-jump list of every admin section. Both duplicated navigation that already lives in the sidebar (and the global nav after Phase 4b), so the landing page told the admin nothing they didn't already see two clicks away. Replace the duplicated nav with two at-a-glance panes that *only* the overview can show: - Welcome card greets the signed-in admin by username and confirms the admin context (so the role indicator the shell shows is reinforced in the page body, not just the chrome). - `<SystemStatusSummary>` reuses the existing `useSystemStatus()` hook + `/api/version` to print app version, DB up/down, worker state, process start time, build SHA, and built-at. Same data source as `<SystemStatusSection>` so we don't double-fetch. - `<RecentAuditPreview>` lists the latest 10 audit entries from `/api/admin/audit-log?limit=10&filter=auth` with a "View all" link to `/admin/login-overview`. Reuses the same auth-action label map the full section uses. The previous `<StatusCardGrid>` component is now unused — removed entirely. The test file kept its name (`status-card-grid.test.tsx`) so git history follows the rename, but it now asserts the new overview composition: welcome card paints with the admin's username, snapshot heading present, audit preview links to `/admin/login-overview`, and the legacy "Maintenance"/"Monitoring" card titles + "Manage users" CTAs are *not* rendered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3e45a7ba53
commit
a967895808
8 files changed
+665
-489
No files matched your search
@@ -1,165 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { StatusBadge, buildCards } from "../status-card-grid";
|
||||
import { ADMIN_SECTION_SLUGS } from "../section-slugs";
|
||||
import type { StatusOverview } from "@/app/api/admin/status-overview/route";
|
||||
/**
|
||||
* v1.4.15 phase A2: the `/admin` overview no longer renders the
|
||||
* `StatusCardGrid` (the section-card grid Marc described as redundant
|
||||
* with the sidebar nav). The status-card component itself was removed
|
||||
* from the codebase. This test guards the *new* overview composition:
|
||||
* a welcome card, the system snapshot, and the recent-audit preview.
|
||||
*
|
||||
* The file kept its old name so git history follows it. Nothing else
|
||||
* imports `status-card-grid`.
|
||||
*/
|
||||
|
||||
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(),
|
||||
},
|
||||
};
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin",
|
||||
}));
|
||||
|
||||
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"');
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import AdminOverviewPage from "@/app/admin/page";
|
||||
import { SystemStatusSummary } from "../system-status-summary";
|
||||
import { RecentAuditPreview } from "../recent-audit-preview";
|
||||
|
||||
function render(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/admin overview composition", () => {
|
||||
it("does NOT render the legacy section-card grid", () => {
|
||||
const html = render(<AdminOverviewPage />);
|
||||
// The old grid surfaced six card titles ("Users", "Integrations",
|
||||
// "Monitoring", "Backups", "Maintenance", "Audit log") in headings.
|
||||
// The redesigned overview must not paint that grid anymore.
|
||||
expect(html).not.toContain(">Maintenance<");
|
||||
expect(html).not.toContain(">Monitoring<");
|
||||
// No "Manage users"/"Open backups" CTAs from the StatusCard CTAs.
|
||||
expect(html).not.toContain("Manage users");
|
||||
expect(html).not.toContain("Open backups");
|
||||
});
|
||||
|
||||
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("renders the welcome card with the admin's username", () => {
|
||||
const html = render(<AdminOverviewPage />);
|
||||
// `welcomeTitle` interpolates the username; the mocked auth hook
|
||||
// returns "marc".
|
||||
expect(html).toContain("Welcome, marc");
|
||||
expect(html).toContain("admin-overview-welcome-heading");
|
||||
});
|
||||
|
||||
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 real `/admin/<slug>` route that exists on disk", () => {
|
||||
// v1.5: status-card CTAs no longer use `#anchor` fragments — every href
|
||||
// points at a dynamic-route slug under `/admin/[section]`. This test
|
||||
// proves the slug exists (a) in the static `ADMIN_SECTION_SLUGS` list
|
||||
// that drives `generateStaticParams()` AND (b) that the dynamic route
|
||||
// file `src/app/admin/[section]/page.tsx` exists on disk. If a future
|
||||
// refactor moves the dynamic route, the second check fails loudly.
|
||||
const validSlugs = new Set<string>(ADMIN_SECTION_SLUGS);
|
||||
|
||||
// `__dirname` here is `.../src/components/admin/__tests__`, so walk
|
||||
// up four levels to the repo root.
|
||||
const repoRoot = join(__dirname, "..", "..", "..", "..");
|
||||
const dynamicRoute = join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"app",
|
||||
"admin",
|
||||
"[section]",
|
||||
"page.tsx",
|
||||
);
|
||||
expect(
|
||||
existsSync(dynamicRoute),
|
||||
`Dynamic admin route missing — expected ${dynamicRoute}`,
|
||||
).toBe(true);
|
||||
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.cta.length).toBeGreaterThan(0);
|
||||
// Hrefs must be real sub-routes; `/admin#section-*` was the v1.4.6
|
||||
// honesty problem the v1.5 refactor permanently removed.
|
||||
expect(card.href).toMatch(/^\/admin\/[a-z][a-z0-9-]*$/);
|
||||
const slug = card.href.replace(/^\/admin\//, "");
|
||||
expect(
|
||||
validSlugs.has(slug),
|
||||
`Card ${card.title} -> ${card.href} is not in ADMIN_SECTION_SLUGS`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes between 1 and 3 metric tuples per card with non-empty labels", () => {
|
||||
// v1.5 phase-5 a11y fix: dropped the empty-string filler "—"/"" tuple from
|
||||
// the audit-log card so the rendered <dl> has no zero-content <dd>. Cards
|
||||
// must still have at least one metric (otherwise the grid collapses) and
|
||||
// every metric needs a real label (axe `definition-list` rule).
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.metrics.length).toBeGreaterThanOrEqual(1);
|
||||
expect(card.metrics.length).toBeLessThanOrEqual(3);
|
||||
for (const metric of card.metrics) {
|
||||
expect(metric.label.trim()).not.toBe("");
|
||||
expect(metric.label).not.toBe("—");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
it("renders the system snapshot section heading", () => {
|
||||
const html = render(<SystemStatusSummary />);
|
||||
expect(html).toContain("System snapshot");
|
||||
expect(html).toContain("admin-overview-snapshot-heading");
|
||||
});
|
||||
|
||||
it("renders the recent-activity section with a 'View all' link", () => {
|
||||
const html = render(<RecentAuditPreview />);
|
||||
expect(html).toContain("Recent activity");
|
||||
expect(html).toContain("admin-overview-audit-heading");
|
||||
expect(html).toContain('href="/admin/login-overview"');
|
||||
expect(html).toContain("View all");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<RecentAuditPreview>` — compact list of the 10 most recent audit
|
||||
* entries for the `/admin` overview landing page. The full filterable
|
||||
* viewer lives at `/admin/login-overview`.
|
||||
*
|
||||
* Reuses `/api/admin/audit-log` so the data source matches the
|
||||
* detail-page section (`<LoginOverviewSection>`).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
|
||||
const PREVIEW_LIMIT = 10;
|
||||
|
||||
interface AuditLogResponse {
|
||||
entries: AdminAuditEntry[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
export function RecentAuditPreview() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["admin", "audit-log", "overview-preview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load audit log");
|
||||
return (await res.json()).data as AuditLogResponse;
|
||||
},
|
||||
// Avoid hitting the DB on every page focus; the overview is the
|
||||
// type of page admins keep open and re-render frequently.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const AUTH_ACTION_LABELS: Record<string, string> = {
|
||||
"auth.register": t("admin.authRegister"),
|
||||
"auth.login": t("admin.authLogin"),
|
||||
"auth.login.passkey": t("admin.authLoginPasskey"),
|
||||
"auth.login.password": t("admin.authLoginPassword"),
|
||||
"auth.login.failed": t("admin.authLoginFailed"),
|
||||
"auth.logout": t("admin.authLogout"),
|
||||
"auth.passkey.register": t("admin.authPasskeyRegister"),
|
||||
"auth.passkey.delete": t("admin.authPasskeyDelete"),
|
||||
};
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-audit-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-audit-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.auditTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.overview.auditLoading")}
|
||||
</span>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditLoadError")}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.overview.auditEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-border divide-y">
|
||||
{entries.map((entry) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<span className="shrink-0" aria-hidden="true">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</span>
|
||||
<span
|
||||
className={`hidden truncate sm:inline ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"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: "Open integrations",
|
||||
},
|
||||
{
|
||||
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/integrations",
|
||||
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: "Open 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/reminders",
|
||||
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) },
|
||||
],
|
||||
href: "/admin/login-overview",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SystemStatusSummary>` — compact at-a-glance system snapshot for the
|
||||
* `/admin` overview landing page.
|
||||
*
|
||||
* The full breakdown lives in `<SystemStatusSection>` at
|
||||
* `/admin/system-status`; this component only surfaces the handful of
|
||||
* facts an admin wants on the overview screen: app version, database
|
||||
* up/down, worker running, image build SHA + timestamp, and the
|
||||
* server-process start time. Same `useSystemStatus()` data source so we
|
||||
* don't re-fetch.
|
||||
*/
|
||||
|
||||
import {
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
GitCommit,
|
||||
Loader2,
|
||||
Server,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
}
|
||||
|
||||
function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: ["public", "version"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError } = useSystemStatus();
|
||||
const { data: version } = useVersion();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-snapshot-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-snapshot-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.snapshotTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
value={version?.version ?? status.version}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.overview.snapshotDatabase")}
|
||||
value={
|
||||
status.database === "connected"
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
label={t("admin.overview.snapshotWorker")}
|
||||
value={
|
||||
status.worker.running
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-dracula-green" : "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.overview.snapshotStarted")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
{(version?.buildSha ?? status.gitCommit) && (
|
||||
<StatusItem
|
||||
icon={GitCommit}
|
||||
label={t("admin.overview.snapshotBuildSha")}
|
||||
value={version?.buildSha ?? status.gitCommit}
|
||||
/>
|
||||
)}
|
||||
{version?.builtAt && (
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.overview.snapshotBuiltAt")}
|
||||
value={formatDateTime(version.builtAt)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.overview.snapshotLoadError")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.overview.snapshotLoading")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user