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:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 12:47:21 +02:00
1 parent f4a2e13985
commit 725cace865
5 files changed
+767

No files matched your search

+3
View File
@@ -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);
});
});
+217
View File
@@ -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");
});
});
+258
View File
@@ -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>
);
}