Le tableau de bord /admin/monitoring fonctionnait en polling côté client (15 s) déclenchant une sonde active côté serveur à chaque appel — deux onglets admin ouverts = deux séries de sondes redondantes. Inverse le mécanisme : le serveur sonde une fois par minute et pousse le résultat aux clients connectés via WebSocket (socket.io, room "monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne en continu dès le démarrage, sans lien avec la présence d'un admin connecté (le plus simple des deux, coût des sondes négligeable). Backend : - ExpressServer (packages/express-tools) expose désormais son http.Server sous-jacent (créé explicitement, plus dans .listen()) — socket.io s'attache au serveur HTTP lui-même, pas à Express/un port. - MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune duplication), diffuse à la room "monitoring", rejoue le dernier résultat en cache à tout nouvel admin qui rejoint (pas d'attente jusqu'à une minute pour un onglet fraîchement ouvert). - requireAdminSocket (nouveau) : middleware de handshake socket.io, équivalent WebSocket de requireAdmin — même vérification (cookie + JWT + tokenVersion), factorisée dans resolveAdminFromToken (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent le cookie brut (un handshake socket.io ne passe pas par cookie-parser). - GET /admin/api/monitoring supprimée (route + service de test associés). - server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont pas besoin d'un serveur WebSocket. Frontend : - MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper socket.io-client, interface MonitoringSocketClientLike volontairement petite et injectable dans MonitoringPage (prop optionnelle) pour rester testable sans serveur réel. - MonitoringPage : data et connectionState séparés — une coupure de connexion après un premier chargement affiche un badge "reconnexion en cours" sans jamais effacer le dernier board connu (repose sur la reconnexion automatique de socket.io-client). Seul le tout premier chargement affiche l'état plein-écran chargement/erreur. - adminApiClient.getMonitoring() supprimée (plus utilisée). Tests : - Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion socket.io-client contre un serveur réel démarré dans le test (port 0), authentification avec un admin seedé + cookie signé, vérifie la réception de monitoring:update et le rejet sans cookie admin valide. Les tests heartbeat de l'ancien admin-monitoring.test.ts (route /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont déplacés vers tech-step-worker.routes.test.ts, leur router d'origine. - Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un client factice, vérifie rendu des cartes, badge de reconnexion sans perte du board, états chargement/erreur — pas de test e2e Gherkin contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo, fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur). - apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé par le test composant ci-dessus. Vérification : pnpm lint clean, build complet du monorepo clean, suite Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable, comme pour #33) — le nouveau test socket.io n'a donc pas pu être run-vérifié, seulement relu attentivement et vérifié par le lint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
193 lines
7.2 KiB
TypeScript
193 lines
7.2 KiB
TypeScript
import {
|
||
type AdminLoginInput,
|
||
type AdminUserView,
|
||
type ApiErrorResponse,
|
||
type CatalogPlaceholderGroupView,
|
||
type CorrectionAdminView,
|
||
ErrorCode,
|
||
type MarkPlaceholdersReviewedInput,
|
||
type MetricsView,
|
||
type PruneOrphansResultView,
|
||
type RetrainRequestInput,
|
||
type RetrainResultView,
|
||
type TrainingDataSnippetView,
|
||
type TrainingSuggestionAdminView,
|
||
type TrainingSuggestionGroupView,
|
||
type UpdateTrainingSuggestionInput,
|
||
} from "@batch-cooking/shared";
|
||
|
||
/** Builds a `?a=b&c=d` string from defined values only. */
|
||
function query(params: Record<string, string | undefined>): string {
|
||
const entries = Object.entries(params).filter(
|
||
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "",
|
||
);
|
||
return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`;
|
||
}
|
||
|
||
/**
|
||
* Base URL of the admin API — the same `VITE_API_URL` the user-facing
|
||
* {@link apiClient} reads (see `api/client.ts`), plus the `/admin/api`
|
||
* prefix every route below is relative to. That prefix (not just bare
|
||
* `/admin`) exists because this API is served from the same origin/process
|
||
* as apps/web's own `/admin/*` page routes (`/admin/monitoring`,
|
||
* `/admin/login`...) — a bare `/admin` mount put `GET /admin/monitoring`
|
||
* (API) and the page at that same path in direct conflict, so a hard
|
||
* refresh on the monitoring page got answered with raw JSON instead of the
|
||
* SPA (see `mountRouter("/admin/api", ...)`'s own comment in the API's
|
||
* `app.ts` for the full incident). Defaults to `/admin/api` (same origin)
|
||
* — correct behind a shared reverse proxy; native dev overrides
|
||
* `VITE_API_URL` to `http://localhost:3000` in `apps/web/.env`.
|
||
*/
|
||
const ADMIN_API_BASE_URL: string = `${import.meta.env.VITE_API_URL ?? ""}/admin/api`;
|
||
|
||
/**
|
||
* Thrown by {@link AdminApiClient} on any non-2xx response — carries the
|
||
* {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s
|
||
* `ApiError` (same shape) so a file importing both never has a name clash;
|
||
* the admin transport layer stays independent of the user-facing one.
|
||
*/
|
||
export class AdminApiError extends Error {
|
||
public readonly status: number;
|
||
public readonly code: ErrorCode;
|
||
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
||
|
||
public constructor(status: number, body: ApiErrorResponse) {
|
||
super(body.message);
|
||
this.name = "AdminApiError";
|
||
this.status = status;
|
||
this.code = body.code;
|
||
this.fieldErrors = body.details;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Thin fetch wrapper around the `/admin/api/*` endpoints — same design as
|
||
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
|
||
* stateless instance). Every request sends credentials so the
|
||
* `admin_session` httpOnly cookie round-trips.
|
||
*/
|
||
export class AdminApiClient {
|
||
/**
|
||
* Performs a JSON request against the admin API and returns the parsed body.
|
||
*
|
||
* @throws {AdminApiError} if the response status is not in the 2xx range.
|
||
*/
|
||
private async _request<TResponseBody>(
|
||
path: string,
|
||
options: RequestInit = {},
|
||
): Promise<TResponseBody> {
|
||
try {
|
||
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
|
||
...options,
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json", ...options.headers },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||
throw new AdminApiError(
|
||
response.status,
|
||
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||
);
|
||
}
|
||
|
||
if (response.status === 204) {
|
||
return undefined as TResponseBody;
|
||
}
|
||
return (await response.json()) as TResponseBody;
|
||
} catch (err) {
|
||
// Rethrown as-is — callers surface it their own way; this is just the
|
||
// one place the fetch/`await` sits in a try/catch per the repo's rule.
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Verifies admin credentials and starts an admin session. */
|
||
public login(input: AdminLoginInput): Promise<AdminUserView> {
|
||
return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||
}
|
||
|
||
/** Ends the current admin session. */
|
||
public logout(): Promise<void> {
|
||
return this._request("/auth/logout", { method: "POST" });
|
||
}
|
||
|
||
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||
public me(): Promise<AdminUserView> {
|
||
return this._request("/auth/me");
|
||
}
|
||
|
||
/** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */
|
||
public getMetrics(days: number): Promise<MetricsView> {
|
||
return this._request(`/metrics?days=${days}`);
|
||
}
|
||
|
||
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||
public getSuggestions(filters: {
|
||
status?: string;
|
||
sourceType?: string;
|
||
techStepKey?: string;
|
||
locale?: string;
|
||
}): Promise<TrainingSuggestionGroupView[]> {
|
||
return this._request(`/tech-steps/suggestions${query(filters)}`);
|
||
}
|
||
|
||
/** Raw user corrections, including the "no technique here" removals. */
|
||
public getCorrections(filters: {
|
||
consumed?: string;
|
||
hasCorrectedTechStep?: string;
|
||
}): Promise<CorrectionAdminView[]> {
|
||
return this._request(`/tech-steps/corrections${query(filters)}`);
|
||
}
|
||
|
||
/** Edits a suggestion's proposed synonyms/utterances and/or its status. */
|
||
public updateSuggestion(
|
||
id: number,
|
||
body: UpdateTrainingSuggestionInput,
|
||
): Promise<TrainingSuggestionAdminView> {
|
||
return this._request(`/tech-steps/suggestions/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */
|
||
public getTrainingDataSnippet(params: {
|
||
techStepKey: string;
|
||
locale?: string;
|
||
status?: string;
|
||
}): Promise<TrainingDataSnippetView> {
|
||
return this._request(`/tech-steps/training-data-snippet${query(params)}`);
|
||
}
|
||
|
||
/** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */
|
||
public retrain(body: RetrainRequestInput): Promise<RetrainResultView> {
|
||
return this._request("/tech-steps/retrain", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */
|
||
public getPlaceholders(reviewed?: "true" | "false"): Promise<CatalogPlaceholderGroupView[]> {
|
||
return this._request(`/catalog/placeholders${query({ reviewed })}`);
|
||
}
|
||
|
||
/** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */
|
||
public markPlaceholdersReviewed(
|
||
body: MarkPlaceholdersReviewedInput,
|
||
): Promise<{ reviewed: number }> {
|
||
return this._request("/catalog/placeholders/mark-reviewed", {
|
||
method: "PATCH",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/** Deletes placeholder rows no recipe references any more. */
|
||
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
|
||
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
||
}
|
||
}
|
||
|
||
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||
export const adminApiClient = new AdminApiClient();
|