perf: lot 1 — chargements plus rapides et corrections, sans changement visuel

Grille (navigateur)
- « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait
  mémorisé et chaque retour par le menu forçait un recalcul serveur complet
  (migration du store pour nettoyer les valeurs déjà enregistrées).
- Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le
  tableau à chaque paquet de 150 (chargement quadratique).
- Sélection indexée par code article (getRowId) : après un filtre, l'action
  groupée visait d'autres produits. Seules les lignes affichées comptent.
- Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de
  nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ;
  plus de transition-all sur les lignes positionnées par transform.
- exceljs, jspdf et xlsx chargés au clic seulement.

Grille (serveur)
- Requêtes Qlik et snapshot lancées en parallèle de la phase SQL.
- Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ;
  flux interrompu quand le client part.
- Après un enregistrement, les gammes sont reportées dans le cache et dans
  grid_rows au lieu d'invalider (recalcul de ~40 s évité).

Données et API
- lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade,
  CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de
  synchro nocturne ; erreurs jamais gardées.
- Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé
  une fois par connexion au lieu d'une transaction autour de chaque requête.
- Délai maximal sur tous les appels à l'API FF.
- Index session_snapshots ; commandes : plus de double rechargement ;
  synchro : saisies regroupées ; paramètres : configuration lue une fois ;
  journal de capture en ajout seul, rien de formaté sans capture ouverte.

Corrections
- Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »).
- Sessions et validations enregistrent le magasin affiché, plus « TOTAL ».
- Publicités : libellé du statut « passées ».

Plan complet : docs/plan-optimisation-webui.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 20:28:14 +00:00
1 parent 2c84cfda53
commit 7da4c6d5fd
35 files changed
+1010 -240

No files matched your search

+36 -11
View File
@@ -4,12 +4,19 @@ import * as schema from "./schema";
import fs from "fs";
import path from "path";
let pool: Pool | null = null;
let currentDb: NodePgDatabase<typeof schema> | null = null;
/**
* Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro
* nocturne) et les routes peuvent être compilées dans des lots distincts, chacun
* avec sa propre copie de ce module — et donc son propre pool de 20 connexions.
* Même chose à chaque rechargement à chaud en développement.
*/
type DbState = { pool: Pool | null; db: NodePgDatabase<typeof schema> | null };
const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState };
const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null });
export function getPool(): Pool {
getDb(); // ensure pool is initialized
return pool!;
return state.pool!;
}
function maskUrl(url: string | undefined) {
@@ -21,7 +28,7 @@ function maskUrl(url: string | undefined) {
* Initializes or returns the current database instance.
*/
export function getDb() {
if (currentDb) return currentDb;
if (state.db) return state.db;
let connectionString = "";
@@ -54,19 +61,37 @@ export function getDb() {
const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown";
console.log(`[DB] Target Hostname: ${connectionHostname}`);
pool = new Pool({
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
// fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise
// pas indéfiniment une des 20 connexions.
const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined;
const pool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
application_name: "collectflow",
...(statementTimeout ? { statement_timeout: statementTimeout } : {}),
});
pool.on('error', (err) => {
console.error('[DB] Unexpected error on idle client', err);
});
currentDb = drizzle(pool, { schema });
return currentDb;
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit
// dans le conteneur, et font échouer les grosses agrégations. Réglé une fois
// par connexion (la requête est mise en file avant toute autre sur ce client)
// au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête.
pool.on('connect', (client) => {
client.query("SET max_parallel_workers_per_gather = 0").catch((err) => {
console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message);
});
});
state.pool = pool;
state.db = drizzle(pool, { schema });
return state.db;
}
/**
@@ -75,11 +100,11 @@ export function getDb() {
*/
export function refreshDb() {
console.log("[DB] Refreshing database connection...");
if (pool) {
pool.end().catch(err => console.error("[DB] Error closing pool:", err));
pool = null;
if (state.pool) {
state.pool.end().catch(err => console.error("[DB] Error closing pool:", err));
state.pool = null;
}
currentDb = null;
state.db = null;
}
/**