Files
mcp-wms-api/src/services/log-service.js
T
Arthur Ria 9b95e15cbc Nettoyage du dépôt : doublons, code mort, secrets, build
Fichiers hors périmètre ou dupliqués :
- suppression des 5 .md dupliqués à la racine (copies md5-identiques de
  docs/api/ et docs/entities/)
- suppression de JANITOR_main.js / JANITOR_entities.json (application
  Electron sans lien avec le serveur MCP)
- suppression de temp/*.json (dumps de workflows versionnés par accident)
  et ajout de temp/ au .gitignore
- suppression de claude_desktop_config_ssh.json : mots de passe en clair et
  variables ORACLE_* d'une architecture abandonnée
- AD_API_TEST_RESULTS.md -> docs/ad-api-validation.md (credentials du
  snippet remplacés par des variables d'environnement)
- suppression d'IMPLEMENTATION_SUMMARY.md, doublon du précédent
- queries api.php -> docs/reference-queries-api.php (renommage seul)

Code mort :
- suppression de src/resources/documentation.js : la resource docs:// n'a
  jamais été branchée dans src/index.js
- suppression de src/config/constants.js : module entièrement inutilisé,
  requis par wms-query-service.js mais dont aucune constante n'était lue.
  Emporte RESOURCE_URIS.WORKFLOWS_CATEGORIES, URI déclarée jamais servie.
- log-service.js : suppression de findRecentErrors, readFullLog et
  getLogStats, exportées mais exposées par aucun outil MCP
- suppression de LOG_FILE_PATTERN (lue depuis .env, jamais appliquée : le
  scan filtre sur .log en dur), y compris dans .env.example
- log-service.js : préfixe [Logs] sur les messages, comme les autres modules

Secrets :
- test-ad-api.ps1 -> scripts/test-ad-api.ps1, credentials passés en
  paramètres ou par WMS_USERNAME / WMS_PASSWORD au lieu d'être en dur

Build et test :
- @yao-pkg/pkg en devDependency, cible node22-win-x64 : npm run build
  échouait faute de pkg, et node20 n'a pas de binaire prébuilt (bascule sur
  une compilation de Node qui échoue sans toolchain MSVC)
- index.js : le .env est lu à côté de l'exécutable quand le serveur est
  packagé. Avec un chemin statique, pkg embarquait le .env dans le snapshot,
  figeant les credentials dans le binaire.
- scripts/test-connection.js : npm test pointait sur un fichier absent.
  Smoke test en lecture seule (OAuth, QueryExecute, QueryScalarExecute,
  API AD), par profil ou sur tous.

Vérifié après nettoyage : 23 outils et 6 resources répondent au handshake
MCP, npm test passe 4/4 contre le WMS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:15:08 +02:00

308 lines
9.5 KiB
JavaScript

const fs = require('fs').promises;
const path = require('path');
const profileManager = require('../config/profile-manager');
/**
* Service de gestion des logs
* Supporte plusieurs emplacements de logs :
* - IIS logs: \\<host>\inetpub\logs\LogFiles\Mecalux (avec sous-dossiers)
* - ETL middleware: \\<host>\ProgramData\Mecalux\ETLLogs
*
* LOGS_PATH dans .env peut contenir le placeholder "{host}" qui est remplacé
* par le host du profil actif à chaque appel.
*
* En mode SaaS (profile.saas === true) l'accès aux logs est bloqué car le
* WMS est hébergé dans le cloud et le filesystem n'est pas joignable.
*/
const DEFAULT_LOG_PATHS = [
'\\\\{host}\\inetpub\\logs\\LogFiles\\Mecalux',
'\\\\{host}\\ProgramData\\Mecalux\\ETLLogs',
];
/**
* Retourne les chemins de logs à scanner pour le profil actif.
* Substitue {host} par l'hostname du profil.
* Lance une erreur si le profil est en mode SaaS.
*/
function getLogPaths() {
const profile = profileManager.getCurrent();
if (profile.saas) {
throw new Error(`Log access is disabled for SaaS profile "${profile.name}". The WMS is cloud-hosted — local log files are not reachable. Use the WMS API (query/command/workflow tools) instead.`);
}
const templates = process.env.LOGS_PATH
? process.env.LOGS_PATH.split(';').map(p => p.trim()).filter(Boolean)
: DEFAULT_LOG_PATHS;
return templates.map(t => t.replace(/\{host\}/g, profile.host));
}
/**
* Scanne récursivement un dossier pour trouver tous les fichiers .log
* @param {string} dir - Dossier à scanner
* @param {Array} fileList - Liste accumulée des fichiers (pour la récursion)
* @returns {Promise<Array>} - Liste des fichiers trouvés
*/
async function scanLogsRecursively(dir, fileList = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Récursion dans les sous-dossiers
await scanLogsRecursively(fullPath, fileList);
} else if (entry.isFile() && entry.name.endsWith('.log')) {
try {
const stats = await fs.stat(fullPath);
fileList.push({
name: entry.name,
path: fullPath,
relativePath: path.relative(dir, fullPath),
directory: path.dirname(fullPath),
size: stats.size,
modified: stats.mtime,
});
} catch (statErr) {
// Ignorer les fichiers inaccessibles
console.error(`[Logs] Cannot access file ${fullPath}: ${statErr.message}`);
}
}
}
} catch (err) {
// Ne pas planter si un dossier n'existe pas ou n'est pas accessible
console.error(`[Logs] Cannot scan directory ${dir}: ${err.message}`);
}
return fileList;
}
/**
* Liste tous les fichiers de logs dans tous les répertoires configurés
* @returns {Promise<Array>} - Liste des fichiers de logs
*/
async function listLogFiles() {
const logPaths = getLogPaths();
const allFiles = [];
// Scanner chaque chemin configuré
for (const logPath of logPaths) {
const files = await scanLogsRecursively(logPath);
allFiles.push(...files);
}
if (allFiles.length === 0) {
const pathsList = logPaths.join(', ');
throw new Error(`No log files found in configured paths: ${pathsList}`);
}
// Trier par date de modification (plus récent en premier)
allFiles.sort((a, b) => b.modified - a.modified);
return allFiles;
}
/**
* Trouve le fichier de log le plus récent
* @returns {Promise<string>} - Chemin du fichier le plus récent
*/
async function findLatestLogFile() {
const files = await listLogFiles();
if (files.length === 0) {
throw new Error('No log files found');
}
return files[0].path;
}
/**
* Vérifie si un fichier existe
* @param {string} filePath
* @returns {Promise<boolean>}
*/
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Résout le chemin d'un fichier de log en 3 passes :
* 1. Chemin direct (LOGS_PATH + logFile, peut inclure un sous-dossier)
* 2. Nom de fichier exact dans chaque sous-dossier immédiat de LOGS_PATH
* 3. Correspondance floue (partielle, insensible à la casse)
* En cas d'échec, retourne une erreur avec la liste des fichiers disponibles.
* @param {string} logFile - Nom ou chemin partiel du fichier
* @returns {Promise<string>} - Chemin absolu résolu
*/
async function resolveLogFilePath(logFile) {
// Si chemin absolu fourni directement
if (path.isAbsolute(logFile)) {
if (await fileExists(logFile)) return logFile;
throw new Error(`File not found: ${logFile}`);
}
const basePath = getLogPaths()[0];
// Passe 1 — Chemin direct (ex: "ApplicationDictionary\ApplicationDictionary.log")
const directPath = path.join(basePath, logFile);
if (await fileExists(directPath)) return directPath;
// Passe 2 & 3 — Scanner les sous-dossiers immédiats
const fileName = path.basename(logFile);
const fileNameNoExt = path.basename(fileName, path.extname(fileName)).toLowerCase();
let subEntries = [];
try {
subEntries = await fs.readdir(basePath, { withFileTypes: true });
} catch (err) {
console.error(`[Logs] Cannot read base log path ${basePath}: ${err.message}`);
}
const subDirs = subEntries.filter(e => e.isDirectory()).map(e => e.name);
// Passe 2 — Nom exact dans les sous-dossiers
for (const dir of subDirs) {
const candidate = path.join(basePath, dir, fileName);
if (await fileExists(candidate)) return candidate;
}
// Passe 3 — Correspondance floue
for (const dir of subDirs) {
let dirFiles = [];
try {
dirFiles = await fs.readdir(path.join(basePath, dir));
} catch {
continue;
}
for (const f of dirFiles) {
if (!f.endsWith('.log')) continue;
const fNoExt = path.basename(f, path.extname(f)).toLowerCase();
if (fNoExt.includes(fileNameNoExt) || fileNameNoExt.includes(fNoExt)) {
return path.join(basePath, dir, f);
}
}
}
// Aucun fichier trouvé — construire une liste utile
const available = [];
for (const dir of subDirs) {
try {
const files = await fs.readdir(path.join(basePath, dir));
files.filter(f => f.endsWith('.log')).forEach(f => available.push(`${dir}\\${f}`));
} catch { /* ignorer */ }
}
const availableMsg = available.length > 0
? `Available log files:\n${available.join('\n')}`
: `No log files found in ${basePath}`;
throw new Error(`Log file "${logFile}" not found.\n${availableMsg}`);
}
/**
* Lit les dernières lignes d'un fichier de log
* @param {number} count - Nombre de lignes à lire
* @param {string} logFile - Nom ou chemin partiel du fichier (optionnel, prend le plus récent par défaut)
* @returns {Promise<Object>} - Lignes du log
*/
async function readRecentLogs(count = 100, logFile = null) {
try {
const filePath = logFile
? await resolveLogFilePath(logFile)
: await findLatestLogFile();
// Lire le fichier complet
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() !== '');
// Prendre les dernières lignes
const recentLines = lines.slice(-count);
return {
file: path.basename(filePath),
fullPath: filePath,
totalLines: lines.length,
returnedLines: recentLines.length,
lines: recentLines,
};
} catch (err) {
throw new Error(`Failed to read log file: ${err.message}`);
}
}
/**
* Recherche un mot-clé dans les fichiers de logs
* @param {string} keyword - Mot-clé à rechercher
* @param {number} maxResults - Nombre maximum de résultats
* @param {number} contextLines - Nombre de lignes de contexte avant/après
* @returns {Promise<Object>} - Résultats de la recherche
*/
async function searchLogs(keyword, maxResults = 50, contextLines = 2) {
try {
const files = await listLogFiles();
const results = [];
const searchPattern = keyword.toLowerCase();
// Rechercher dans chaque fichier (du plus récent au plus ancien)
for (const file of files) {
if (results.length >= maxResults) break;
try {
const content = await fs.readFile(file.path, 'utf8');
const lines = content.split('\n');
// Rechercher dans chaque ligne
for (let i = 0; i < lines.length; i++) {
if (results.length >= maxResults) break;
const line = lines[i];
if (line.toLowerCase().includes(searchPattern)) {
// Extraire le contexte (lignes avant et après)
const start = Math.max(0, i - contextLines);
const end = Math.min(lines.length, i + contextLines + 1);
const context = lines.slice(start, end);
results.push({
file: file.name,
fullPath: file.path,
directory: file.directory,
lineNumber: i + 1,
line: line.trim(),
context: context.map((l, idx) => ({
lineNumber: start + idx + 1,
text: l.trim(),
isMatch: start + idx === i,
})),
});
}
}
} catch (readErr) {
// Ignorer les fichiers illisibles
console.error(`[Logs] Cannot read file ${file.path}: ${readErr.message}`);
}
}
return {
keyword,
totalResults: results.length,
results,
scannedPaths: getLogPaths(),
};
} catch (err) {
throw new Error(`Search failed: ${err.message}`);
}
}
module.exports = {
listLogFiles,
findLatestLogFile,
readRecentLogs,
searchLogs,
};