mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Parametres : telechargement des journaux serveur complets
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=<jobId> : 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
5 files changed
+450
No files matched your search
@@ -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() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Journaux serveur — diagnostic des extractions Qlik */}
|
||||
<Section
|
||||
title="Journaux serveur"
|
||||
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
|
||||
>
|
||||
<ServerLogs />
|
||||
</Section>
|
||||
|
||||
{/* Gestion des Utilisateurs */}
|
||||
<Section
|
||||
title="Gestion des Utilisateurs"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
|
||||
*
|
||||
* `GET /api/logs` → liste des journaux disponibles (JSON)
|
||||
* `GET /api/logs?id=<jobId>` → 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<NextResponse | null> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<string | null | undefined>): {
|
||||
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||
*/
|
||||
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LogEntry[]> {
|
||||
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<LogEntry[]>([]);
|
||||
// « 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<string>("");
|
||||
|
||||
/** 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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Journal complet de chaque extraction Qlik (le plus récent en premier).
|
||||
Conservés le temps de la vie du serveur, 20 au maximum.
|
||||
</p>
|
||||
<button
|
||||
onClick={recharger}
|
||||
disabled={etat === "chargement"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
|
||||
>
|
||||
{etat === "chargement"
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{etat === "erreur" && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etat !== "erreur" && logs.length === 0 && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
|
||||
{logs.map((log) => (
|
||||
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
|
||||
{log.id}
|
||||
</span>
|
||||
{log.enCours && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
|
||||
en cours
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-muted)]">
|
||||
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
|
||||
download
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Télécharger
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Capture>();
|
||||
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }>> {
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user