L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
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/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>
|
|
);
|
|
}
|