PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.
Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
la migration).
Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
planning.item_added, tech_step.correction_submitted, shopping_list.viewed.
API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.
Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.
reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import type { MetricsTimeBucket, MetricsView } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
import { adminApiClient } from "../../api/client";
|
|
import "../admin-page.scss";
|
|
import "./dashboard-page.scss";
|
|
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
|
|
|
/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */
|
|
type DashboardState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; metrics: MetricsView }
|
|
| { status: "error" };
|
|
|
|
const RANGE_DAYS = 30;
|
|
|
|
/**
|
|
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
|
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
|
|
* Fed by `GET /admin/metrics`; read-only, refetched only on mount.
|
|
*/
|
|
export function DashboardPage() {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<DashboardState>({ status: "loading" });
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setState({ status: "loading" });
|
|
adminApiClient
|
|
.getMetrics(RANGE_DAYS)
|
|
.then((metrics) => {
|
|
if (!cancelled) setState({ status: "loaded", metrics });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setState({ status: "error" });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="admin-page">
|
|
<h1 className="admin-page__title">{t("admin.dashboard.title")}</h1>
|
|
<p className="admin-page__lead">{t("admin.dashboard.lead")}</p>
|
|
|
|
{state.status === "loading" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
)}
|
|
{state.status === "loaded" && <DashboardBody metrics={state.metrics} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DashboardBody({ metrics }: { metrics: MetricsView }) {
|
|
const { t } = useTranslation();
|
|
const { snapshot, series } = metrics;
|
|
|
|
const charts: { key: string; buckets: MetricsTimeBucket[] }[] = [
|
|
{ key: "signups", buckets: series.signups },
|
|
{ key: "recipesCreated", buckets: series.recipesCreated },
|
|
{ key: "planningItemsAdded", buckets: series.planningItemsAdded },
|
|
{ key: "correctionsSubmitted", buckets: series.correctionsSubmitted },
|
|
{ key: "trainingSuggestions", buckets: series.trainingSuggestions },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<section className="kpi-grid">
|
|
{kpiTiles(snapshot).map((tile) => (
|
|
<div className="kpi-tile" key={tile.labelKey}>
|
|
<span className="kpi-tile__value">{formatCount(tile.value)}</span>
|
|
<span className="kpi-tile__label">{t(`admin.dashboard.kpi.${tile.labelKey}`)}</span>
|
|
</div>
|
|
))}
|
|
</section>
|
|
|
|
<section className="chart-grid">
|
|
{charts.map(({ key, buckets }) => (
|
|
<article className="chart-card" key={key}>
|
|
<header className="chart-card__head">
|
|
<h2>{t(`admin.dashboard.series.${key}`)}</h2>
|
|
<span className="chart-card__total">
|
|
{t("admin.dashboard.windowTotal", { n: seriesTotal(buckets) })}
|
|
</span>
|
|
</header>
|
|
<TrendChart buckets={buckets} />
|
|
</article>
|
|
))}
|
|
</section>
|
|
|
|
<section className="breakdown">
|
|
<h2>{t("admin.dashboard.recipesBySource")}</h2>
|
|
{snapshot.recipesBySource.length === 0 ? (
|
|
<p className="admin-page__placeholder">{t("admin.dashboard.noImports")}</p>
|
|
) : (
|
|
<ul className="breakdown__list">
|
|
{snapshot.recipesBySource.map((row) => (
|
|
<li key={row.key}>
|
|
<span>{row.label}</span>
|
|
<span>{formatCount(row.count)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{metrics.events.length > 0 && (
|
|
<section className="breakdown">
|
|
<h2>{t("admin.dashboard.events")}</h2>
|
|
<ul className="breakdown__list">
|
|
{metrics.events.map((event) => (
|
|
<li key={event.type}>
|
|
<span>{event.type}</span>
|
|
<span>{formatCount(seriesTotal(event.buckets))}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** A compact 30-day line chart for one metrics series. */
|
|
function TrendChart({ buckets }: { buckets: MetricsTimeBucket[] }) {
|
|
const data = buckets.map((bucket) => ({ day: shortDay(bucket.date), count: bucket.count }));
|
|
return (
|
|
<div className="chart-card__body">
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
|
<XAxis
|
|
dataKey="day"
|
|
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
|
|
interval="preserveStartEnd"
|
|
minTickGap={24}
|
|
/>
|
|
<YAxis
|
|
allowDecimals={false}
|
|
width={40}
|
|
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: 8,
|
|
fontSize: 12,
|
|
}}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="count"
|
|
stroke="var(--color-primary)"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|