Files
ferrovia-panel/updater.js
T
mathis 1c68740e41 RELEASE V.4.0.0 : VERSION LONG SUPPORT (LTS)
--> Imports de ligne via fichier GTFS
--> Extrapolation possible des données
--> Générateur de fiches horaires
2026-09-23 17:38:03 +02:00

459 lines
16 KiB
JavaScript

/**
* Ferrovia Panel - Service de Mise à Jour Automatique & Gestionnaire Système
*
* Serveur autonome Node.js (avec ou sans express) pour orchestrer les opérations
* de maintenance : git fetch/pull, npm install, compilation build et redémarrage PM2.
*/
const http = require('http');
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const PORT = parseInt(process.env.UPDATER_PORT || process.env.PORT || '4001', 10);
const APP_DIR = path.resolve(__dirname);
// État global du processus de mise à jour
let updateState = {
status: 'idle', // 'idle' | 'checking' | 'updating' | 'success' | 'error'
progress: 0,
step: '',
error: null,
startedAt: null,
finishedAt: null,
logs: []
};
/**
* Ajout d'une ligne de journal horodatée avec typage
*/
function addLog(text, type = 'info') {
const timestamp = new Date().toLocaleTimeString('fr-FR', { hour12: false });
const logItem = {
time: timestamp,
text: String(text).trim(),
type: type // 'info' | 'stdout' | 'stderr' | 'success' | 'error' | 'cmd'
};
console.log(`[${timestamp}] [${type.toUpperCase()}] ${logItem.text}`);
updateState.logs.push(logItem);
// Conserver les 300 derniers messages pour éviter la saturation mémoire
if (updateState.logs.length > 300) {
updateState.logs.shift();
}
}
/**
* Exécute une commande shell de manière asynchrone avec streaming des logs
*/
function runCommand(command, args, stepName, progressStart, progressEnd, options = {}) {
return new Promise((resolve, reject) => {
updateState.step = stepName;
updateState.progress = progressStart;
addLog(`>>> Exécution: ${command} ${args.join(' ')}`, 'cmd');
const proc = spawn(command, args, {
cwd: APP_DIR,
shell: true,
env: { ...process.env, ...(options.env || {}) }
});
proc.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
lines.forEach(line => addLog(line, 'stdout'));
});
proc.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
lines.forEach(line => {
// Certains outils (npm, git) écrivent des infos normales sur stderr
const isRealError = /error|fatal|fail/i.test(line);
addLog(line, isRealError ? 'stderr' : 'info');
});
});
proc.on('error', (err) => {
addLog(`Erreur de lancement de la commande: ${err.message}`, 'error');
reject(err);
});
proc.on('close', (code) => {
if (code !== 0) {
const errMsg = `La commande "${command} ${args.join(' ')}" a échoué avec le code de sortie ${code}`;
addLog(`>>> ${errMsg}`, 'error');
reject(new Error(errMsg));
} else {
updateState.progress = progressEnd;
addLog(`>>> Étape réussie : ${stepName}`, 'success');
resolve();
}
});
});
}
/**
* Récupère les métadonnées de version et de dépôt local
*/
function getLocalSystemInfo() {
let version = '0.1.0';
try {
const pkgJson = JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8'));
version = pkgJson.version || version;
} catch (e) {
// Ignorer si package.json non lisible
}
let branch = 'main';
let commitHash = '';
let commitFullHash = '';
let commitMessage = '';
let commitDate = '';
let commitAuthor = '';
let remoteUrl = '';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
branch = 'inconnu';
}
try {
commitHash = execSync('git rev-parse --short HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitFullHash = execSync('git rev-parse HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitMessage = execSync('git log -1 --pretty=%B', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitDate = execSync('git log -1 --format=%cd --date=format:"%d/%m/%Y %H:%M:%S"', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitAuthor = execSync('git log -1 --format=%an', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
// Ignorer si non disponible
}
try {
remoteUrl = execSync('git config --get remote.origin.url', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
remoteUrl = '';
}
return {
version,
branch,
commitHash,
commitFullHash,
commitMessage,
commitDate,
commitAuthor,
remoteUrl,
nodeVersion: process.version,
platform: process.platform,
pid: process.pid,
uptime: Math.round(process.uptime())
};
}
/**
* Vérifie l'état des mises à jour sur le dépôt Git distant
*/
async function checkGitUpdates() {
try {
// 1. Fetch silencieux
execSync('git fetch origin', { cwd: APP_DIR, encoding: 'utf8', timeout: 15000 });
let branch = 'main';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {}
// 2. Compter les commits de retard
let commitsBehind = 0;
try {
const behindCountStr = execSync(`git rev-list --count HEAD..origin/${branch}`, { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitsBehind = parseInt(behindCountStr, 10) || 0;
} catch (e) {
// Si pas de branche distante correspondante
commitsBehind = 0;
}
// 3. Récupérer la liste des commits entrants
let incomingCommits = [];
if (commitsBehind > 0) {
try {
const logOutput = execSync(`git log HEAD..origin/${branch} --format="%h|%an|%ar|%ad|%s" --date=format:"%d/%m/%Y %H:%M" -n 25`, {
cwd: APP_DIR,
encoding: 'utf8'
}).trim();
if (logOutput) {
incomingCommits = logOutput.split('\n').map(line => {
const [hash, author, relativeDate, date, ...msgParts] = line.split('|');
return {
hash,
author,
relativeDate,
date,
message: msgParts.join('|')
};
});
}
} catch (e) {
// Ignore parsing errors
}
}
const localInfo = getLocalSystemInfo();
return {
success: true,
hasUpdate: commitsBehind > 0,
commitsBehind,
branch,
incomingCommits,
localInfo,
checkedAt: new Date().toISOString()
};
} catch (err) {
return {
success: false,
hasUpdate: false,
error: `Impossible de contacter le dépôt distant : ${err.message}`,
localInfo: getLocalSystemInfo(),
checkedAt: new Date().toISOString()
};
}
}
/**
* Pipeline d'exécution de la mise à jour
*/
async function executeUpdatePipeline(options = {}) {
if (updateState.status === 'updating') {
throw new Error('Une mise à jour est déjà en cours.');
}
updateState = {
status: 'updating',
progress: 5,
step: 'Initialisation de la mise à jour',
error: null,
startedAt: new Date().toISOString(),
finishedAt: null,
logs: []
};
addLog('🚀 Démarrage du pipeline de mise à jour Ferrovia Panel...', 'info');
addLog(`Répertoire applicatif : ${APP_DIR}`, 'info');
try {
// Étape 1 : Récupération Git (5% -> 35%)
let branch = 'main';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {}
await runCommand('git', ['fetch', 'origin'], 'Synchronisation des métadonnées Git', 10, 20);
// Si modifications locales non commitées, faire un stash temporaire sécurisé
let hasStashed = false;
try {
const statusOutput = execSync('git status --porcelain', { cwd: APP_DIR, encoding: 'utf8' }).trim();
if (statusOutput && options.stash !== false) {
addLog('Modifications locales détectées, mise en réserve (git stash)...', 'info');
execSync('git stash', { cwd: APP_DIR, encoding: 'utf8' });
hasStashed = true;
}
} catch (e) {}
await runCommand('git', ['pull', 'origin', branch], `Téléchargement des dernières modifications (branche ${branch})`, 20, 40);
// Étape 2 : Installation des dépendances NPM (40% -> 65%)
if (options.skipNpmInstall !== true) {
await runCommand('npm', ['install', '--no-audit', '--no-fund'], 'Installation & mise à jour des dépendances (npm install)', 40, 65);
} else {
addLog('Étape npm install ignorée selon les paramètres.', 'info');
updateState.progress = 65;
}
// Étape 3 : Compilation de l'application React (65% -> 90%)
if (options.skipBuild !== true) {
await runCommand('npm', ['run', 'build'], 'Compilation de l\'application (npm run build)', 65, 90);
} else {
addLog('Étape de build ignorée selon les paramètres.', 'info');
updateState.progress = 90;
}
// Étape 4 : Détection PM2 & Redémarrage (90% -> 100%)
updateState.step = 'Finalisation du déploiement';
updateState.progress = 95;
let pm2Reloaded = false;
if (options.restartPm2 !== false) {
try {
// Tester si PM2 est actif
execSync('pm2 --version', { stdio: 'ignore' });
addLog('PM2 détecté sur le système. Tentative de rechargement...', 'info');
try {
execSync('pm2 reload all || pm2 restart all', { cwd: APP_DIR, encoding: 'utf8', stdio: 'ignore' });
addLog('Processus PM2 rechargés avec succès.', 'success');
pm2Reloaded = true;
} catch (e) {
addLog(`Avertissement PM2 : ${e.message}`, 'info');
}
} catch (e) {
addLog('PM2 non présent ou non configuré pour ce serveur.', 'info');
}
}
updateState.status = 'success';
updateState.step = 'Mise à jour terminée avec succès';
updateState.progress = 100;
updateState.finishedAt = new Date().toISOString();
addLog('🎉 Félicitations ! Ferrovia Panel a été mis à jour avec succès.', 'success');
return { success: true, pm2Reloaded };
} catch (err) {
updateState.status = 'error';
updateState.step = 'Échec de la mise à jour';
updateState.error = err.message;
updateState.finishedAt = new Date().toISOString();
addLog(`❌ ERREUR CRITIQUE : ${err.message}`, 'error');
throw err;
}
}
/**
* Serveur HTTP natif Node.js avec gestion universelle des en-têtes CORS et JSON
*/
const server = http.createServer(async (req, res) => {
// 1. En-têtes CORS complets
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.setHeader('Access-Control-Max-Age', '86400');
// Répondre aux requêtes preflight OPTIONS
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
const sendJson = (statusCode, data) => {
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
const parseBody = () => {
return new Promise((resolve) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (e) {
resolve({});
}
});
});
};
try {
// --- ROUTE: Health check ---
if (pathname === '/api/health' || pathname === '/api/update/health') {
return sendJson(200, {
status: 'ok',
service: 'ferrovia-updater',
version: '2.0.0',
uptime: Math.round(process.uptime()),
pid: process.pid,
port: PORT,
appDir: APP_DIR
});
}
// --- ROUTE: Local System Information ---
if (pathname === '/api/update/info' && req.method === 'GET') {
const info = getLocalSystemInfo();
return sendJson(200, info);
}
// --- ROUTE: Check Remote Updates ---
if ((pathname === '/api/update/check' || pathname === '/api/check-update') && req.method === 'GET') {
const checkResult = await checkGitUpdates();
return sendJson(200, checkResult);
}
// --- ROUTE: Start Update Pipeline ---
if ((pathname === '/api/update/start' || pathname === '/api/update') && req.method === 'POST') {
if (updateState.status === 'updating') {
return sendJson(409, {
error: 'Une mise à jour est déjà en cours d\'exécution.',
state: updateState
});
}
const body = await parseBody();
// Démarrage asynchrone pour répondre immédiatement au client
executeUpdatePipeline(body).catch(err => {
console.error('[UPDATER_ERROR]', err);
});
return sendJson(202, {
message: 'Le processus de mise à jour a été initialisé.',
status: updateState.status,
startedAt: updateState.startedAt
});
}
// --- ROUTE: Real-time Update Status ---
if (pathname === '/api/update/status' && req.method === 'GET') {
return sendJson(200, updateState);
}
// --- ROUTE: Reset State ---
if (pathname === '/api/update/reset' && req.method === 'POST') {
if (updateState.status === 'updating') {
return sendJson(400, { error: 'Impossible de réinitialiser pendant qu\'une mise à jour s\'exécute.' });
}
updateState = {
status: 'idle',
progress: 0,
step: '',
error: null,
startedAt: null,
finishedAt: null,
logs: []
};
return sendJson(200, { message: 'État réinitialisé avec succès.', state: updateState });
}
// 404 Route non trouvée
return sendJson(404, { error: `Route introuvable : ${req.method} ${pathname}` });
} catch (err) {
console.error('[UPDATER_SERVER_ERROR]', err);
return sendJson(500, { error: err.message || 'Erreur interne du serveur de mise à jour' });
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`====================================================`);
console.log(`🚆 FERROVIA PANEL - UPDATER DAEMON DÉMARRÉ`);
console.log(`📡 Port d'écoute : http://localhost:${PORT}`);
console.log(`📁 Répertoire : ${APP_DIR}`);
console.log(`====================================================`);
});
// Arrêt propre
process.on('SIGTERM', () => {
console.log('Fermeture du serveur updater (SIGTERM)...');
server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
console.log('Fermeture du serveur updater (SIGINT)...');
server.close(() => process.exit(0));
});