From d89d2b323261521f46ffecdef76fb4cceb41fdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:45:14 +0000 Subject: [PATCH] Parametres : telechargement des journaux serveur complets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une extraction Qlik produit plusieurs milliers de lignes de diagnostic. Un terminal les tronque et n'en laisse que la fin, alors que l'information decisive — carte du modele Qlik, champs date retenus, choix des periodes — se trouve en TETE. Diagnostiquer sur la fin du log revient a travailler a l'aveugle. - src/lib/log-capture.ts : instrumente console.log/warn/error une fois, recopie chaque ligne horodatee dans les captures ouvertes, ecrit au fil de l'eau dans le repertoire temporaire (un job qui fait tomber le process laisse quand meme son journal). Bornes : 200 000 lignes, 8 000 caracteres par ligne, 20 fichiers conserves. - GET /api/logs : liste JSON ; GET /api/logs?id= : le fichier complet en piece jointe. Admin uniquement. - La sync Qlik ouvre la capture AVANT son premier log et la ferme dans un finally, avec le statut final — les deux modes (fournisseur et produit). - Parametres : section « Journaux serveur », un bouton Telecharger par job, le plus recent en premier, badge « en cours » pour un job vivant. Aucun secret ne transite dans ces journaux : l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur ni le mot de passe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/settings/page.tsx | 9 + src/app/api/logs/route.ts | 54 +++++ src/app/api/qlik/sync/route.ts | 10 + .../settings/components/server-logs.tsx | 148 +++++++++++ src/lib/log-capture.ts | 229 ++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 src/app/api/logs/route.ts create mode 100644 src/features/settings/components/server-logs.tsx create mode 100644 src/lib/log-capture.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index e288523..bcecf1e 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -8,6 +8,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions"; import { useEffect } from "react"; import { UserManagement } from "@/features/admin/components/user-management"; +import { ServerLogs } from "@/features/settings/components/server-logs"; interface OpenRouterModel { id: string; name: string; free: boolean; } @@ -608,6 +609,14 @@ export default function SettingsPage() { + {/* Journaux serveur — diagnostic des extractions Qlik */} +
+ +
+ {/* Gestion des Utilisateurs */}
` → le journal complet en pièce jointe (text/plain) + * + * Réservé aux admins : un journal d'extraction contient les diagnostics internes + * du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite — + * l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur, + * et jamais le mot de passe Qlik. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { listCaptures, readCapture } from "@/lib/log-capture"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +async function requireAdmin(): Promise { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if ((session.user as { role?: string } | undefined)?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return null; +} + +export async function GET(req: NextRequest) { + const refus = await requireAdmin(); + if (refus) return refus; + + const id = req.nextUrl.searchParams.get("id"); + if (!id) { + return NextResponse.json({ success: true, logs: await listCaptures() }); + } + + const contenu = await readCapture(id); + if (contenu == null) { + return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 }); + } + + // Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans + // un en-tête HTTP. + const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`; + return new NextResponse(contenu, { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${nom}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 5614041..53cf5a0 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -7,6 +7,7 @@ import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT, } from "@/lib/qlik-date-range"; +import { startCapture, stopCapture } from "@/lib/log-capture"; // Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). // On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au @@ -215,6 +216,10 @@ function filterCentralCodes(rawCodes: Array): { * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). */ async function runJob(job: QlikSyncJob): Promise { + // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du + // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — + // c'est précisément la partie qu'un terminal tronque. + startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`); try { const articles = await pgGetArticlesByFournisseur(job.fournisseur); // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). @@ -255,6 +260,8 @@ async function runJob(job: QlikSyncJob): Promise { job.finishedAt = new Date().toISOString(); } catch (e) { applyJobError(job, e); + } finally { + await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); } } @@ -284,6 +291,7 @@ function applyJobError(job: QlikSyncJob, e: unknown): void { * exactement le même extracteur, donc le même détail mensuel. */ async function runProductJob(job: QlikSyncJob): Promise { + startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`); try { const code = job.codeCentrale!; job.requested = 1; @@ -312,6 +320,8 @@ async function runProductJob(job: QlikSyncJob): Promise { console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`); } catch (e) { applyJobError(job, e); + } finally { + await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); } } diff --git a/src/features/settings/components/server-logs.tsx b/src/features/settings/components/server-logs.tsx new file mode 100644 index 0000000..9469d2a --- /dev/null +++ b/src/features/settings/components/server-logs.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react"; + +interface LogEntry { + id: string; + octets: number; + modifieLe: string; + enCours: boolean; +} + +function formatTaille(octets: number): string { + if (octets < 1024) return `${octets} o`; + if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`; + return `${(octets / (1024 * 1024)).toFixed(1)} Mo`; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString("fr-FR", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); +} + +/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */ +async function recupererLogs(): Promise { + const res = await fetch("/api/logs", { cache: "no-store" }); + if (!res.ok) { + throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`); + } + const data = await res.json(); + return Array.isArray(data.logs) ? data.logs : []; +} + +/** + * Journaux serveur des extractions Qlik — consultation et téléchargement. + * + * Une extraction produit plusieurs milliers de lignes de diagnostic. Un + * terminal les tronque et n'en laisse que la fin, alors que l'information + * décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve + * en tête. Le journal complet est donc capturé côté serveur et récupérable ici + * en un fichier. + */ +export function ServerLogs() { + const [logs, setLogs] = useState([]); + // « chargement » dès le départ : le premier appel part au montage, et aucun + // setState ne doit être fait de façon synchrone dans un effet. + const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement"); + const [erreur, setErreur] = useState(""); + + /** Applique un résultat (succès ou échec) à l'état local. */ + const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => { + if (entrees) { + setLogs(entrees); + setErreur(""); + setEtat("idle"); + } else { + setErreur(message); + setEtat("erreur"); + } + }, []); + + // Chargement initial. L'état n'est touché que depuis les callbacks de la + // promesse, jamais dans le corps de l'effet. + useEffect(() => { + recupererLogs() + .then((entrees) => appliquer(entrees, "")) + .catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e))); + }, [appliquer]); + + /** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */ + const recharger = useCallback(() => { + setEtat("chargement"); + recupererLogs() + .then((entrees) => appliquer(entrees, "")) + .catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e))); + }, [appliquer]); + + return ( +
+
+

+ Journal complet de chaque extraction Qlik (le plus récent en premier). + Conservés le temps de la vie du serveur, 20 au maximum. +

+ +
+ + {etat === "erreur" && ( +
+ + {erreur} +
+ )} + + {etat !== "erreur" && logs.length === 0 && ( +

+ Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille. +

+ )} + + {logs.length > 0 && ( +
    + {logs.map((log) => ( +
  • + +
    +
    + + {log.id} + + {log.enCours && ( + + en cours + + )} +
    + + {formatDate(log.modifieLe)} · {formatTaille(log.octets)} + +
    + + + Télécharger + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts new file mode 100644 index 0000000..364245e --- /dev/null +++ b/src/lib/log-capture.ts @@ -0,0 +1,229 @@ +/** + * CollectFlow — Capture des logs serveur d'un job, pour téléchargement. + * + * POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes + * (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne + * vivent que dans la sortie standard du conteneur : un terminal les tronque, + * et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête, + * là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle, + * un diagnostic se fait à l'aveugle. + * + * COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du + * module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée + * dans les captures ouvertes, puis transmise telle quelle à la console + * d'origine (rien n'est retenu, rien ne change dans les logs du conteneur). + * + * ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs + * lignes ne portent pas d'identifiant permettant de les démêler. Chaque + * capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un + * peu de bruit qu'une ligne manquante dans un journal de diagnostic. + * + * Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau : + * un job qui fait tomber le process laisse quand même son journal. + */ + +import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */ +const LOG_DIR = join(tmpdir(), "collectflow-logs"); + +/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */ +const MAX_LIGNES = 200_000; +/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */ +const MAX_LONGUEUR_LIGNE = 8_000; +/** Nombre de journaux conservés sur disque. */ +const MAX_FICHIERS = 20; +/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */ +const FLUSH_MS = 2_000; + +interface Capture { + id: string; + lignes: string[]; + tronque: boolean; + debut: number; + flushEnCours: boolean; + flushDemande: boolean; + dernierFlush: number; +} + +const captures = new Map(); + +/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */ +function fichierPour(id: string): string { + return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`); +} + +function horodatage(): string { + return new Date().toISOString(); +} + +/** Rend une ligne à partir des arguments variadiques de `console.*`. */ +function formater(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +async function flush(capture: Capture): Promise { + if (capture.flushEnCours) { + capture.flushDemande = true; + return; + } + capture.flushEnCours = true; + try { + await mkdir(LOG_DIR, { recursive: true }); + await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8"); + capture.dernierFlush = Date.now(); + } catch { + // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe. + } finally { + capture.flushEnCours = false; + if (capture.flushDemande) { + capture.flushDemande = false; + void flush(capture); + } + } +} + +function ajouter(ligne: string): void { + if (captures.size === 0) return; + const texte = ligne.length > MAX_LONGUEUR_LIGNE + ? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]" + : ligne; + const prefixe = `[${horodatage()}] `; + for (const capture of captures.values()) { + if (capture.lignes.length >= MAX_LIGNES) { + if (!capture.tronque) { + capture.tronque = true; + capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`); + } + continue; + } + capture.lignes.push(prefixe + texte); + if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture); + } +} + +/** + * Instrumente `console` une seule fois, même en développement où le module peut + * être réévalué à chaud (le drapeau est porté par `globalThis`). + */ +function instrumenterConsole(): void { + const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean }; + if (g.__cfLogCaptureInstalle) return; + g.__cfLogCaptureInstalle = true; + + const methodes = ["log", "warn", "error", "info"] as const; + for (const methode of methodes) { + const origine = console[methode].bind(console); + console[methode] = (...args: unknown[]) => { + try { + ajouter(formater(args)); + } catch { + // idem : jamais au détriment du log réel + } + origine(...args); + }; + } +} + +instrumenterConsole(); + +/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */ +async function purger(): Promise { + try { + const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log")); + if (noms.length <= MAX_FICHIERS) return; + const avecDate = await Promise.all( + noms.map(async (nom) => { + const s = await stat(join(LOG_DIR, nom)).catch(() => null); + return { nom, mtime: s?.mtimeMs ?? 0 }; + }), + ); + avecDate.sort((a, b) => b.mtime - a.mtime); + for (const { nom } of avecDate.slice(MAX_FICHIERS)) { + await unlink(join(LOG_DIR, nom)).catch(() => undefined); + } + } catch { + // purge best-effort + } +} + +/** + * Ouvre une capture. Toutes les lignes écrites sur la console à partir de + * maintenant (par n'importe quel code du process, cf. avertissement en tête) + * y sont recopiées jusqu'à `stopCapture`. + */ +export function startCapture(id: string, entete?: string): void { + const capture: Capture = { + id, + lignes: [], + tronque: false, + debut: Date.now(), + flushEnCours: false, + flushDemande: false, + dernierFlush: 0, + }; + captures.set(id, capture); + capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`); + if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`); + void flush(capture); + void purger(); +} + +/** Ferme la capture et écrit son état final sur disque. */ +export async function stopCapture(id: string, pied?: string): Promise { + const capture = captures.get(id); + if (!capture) return; + const duree = Math.round((Date.now() - capture.debut) / 1000); + capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`); + if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`); + captures.delete(id); + await flush(capture); +} + +/** + * Contenu d'un journal : la capture en cours si elle est ouverte, sinon le + * fichier laissé sur disque. `null` si aucun des deux n'existe. + */ +export async function readCapture(id: string): Promise { + const capture = captures.get(id); + if (capture) return capture.lignes.join("\n") + "\n"; + try { + return await readFile(fichierPour(id), "utf8"); + } catch { + return null; + } +} + +/** Journaux disponibles, du plus récent au plus ancien. */ +export async function listCaptures(): Promise> { + const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = []; + try { + for (const nom of await readdir(LOG_DIR)) { + if (!nom.endsWith(".log")) continue; + const s = await stat(join(LOG_DIR, nom)).catch(() => null); + if (!s) continue; + const id = nom.slice(0, -4); + resultats.push({ + id, + octets: s.size, + modifieLe: new Date(s.mtimeMs).toISOString(), + enCours: captures.has(id), + }); + } + } catch { + // répertoire pas encore créé + } + return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe)); +}