Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de
l'API au lieu de la page — même chose à craindre pour tout futur /admin/*
GET sans segment supplémentaire.
Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait
l'API admin exactement sous /admin, la même racine que les pages
/admin/* d'apps/web, servies depuis la même origine/le même process en
prod (image Docker unique). Express matche par chemin, sans égard au
Accept — `GET /admin/monitoring` (route API) et la page du même nom
étaient en conflit direct ; l'API gagnait systématiquement (montée
avant serveStaticFrontend), donc chaque F5 sur cette page recevait le
JSON de l'API plutôt que index.html.
Seule /admin/monitoring collidait réellement aujourd'hui (seul
sous-routeur admin à répondre GET sur sa racine bare) mais le
mécanisme touche toute future route admin nommée comme sa page — d'où
un correctif structurel plutôt qu'un renommage ponctuel.
Correctif : l'API admin est montée sous /admin/api (app.ts), jamais
bare /admin. Toute la surface admin bouge en conséquence
(auth/metrics/monitoring/tech-steps/catalog) ; les pages front
(/admin/monitoring, /admin/catalogue, /admin/corrections...) sont
inchangées, seul admin-client.ts change de base URL. Mis à jour en
cohérence : commentaires JSDoc citant les routes (apps/api + apps/web +
packages/shared), specs/backend-architecture.md,
specs/batch-cooking-modele.md, les intercepts Cypress (globs + un
matcher regex qui avait échappé au premier passage) et les chemins
supertest du suite Mocha admin-*.test.ts.
Simplifie au passage admin-monitoring.cy.ts : le contournement
resourceType ajouté en #32 pour cette même collision n'a plus lieu
d'être une fois les chemins distincts (retour à un intercept déclaratif
simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en
commentaire pour référence future.
Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète
28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*).
Suite Mocha apps/api non exécutable cette session (Postgres/Docker
injoignable) — chemins mis à jour mécaniquement en cohérence avec le
nouveau mount point, à faire tourner en CI/à la prochaine session avec
accès DB.
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/admin-client";
|
|
import "../admin-page.scss";
|
|
import "./dashboard-page.scss";
|
|
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
|
|
|
/** Load state for `GET /admin/api/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/api/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>
|
|
);
|
|
}
|