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)); +}