--> Imports de ligne via fichier GTFS --> Extrapolation possible des données --> Générateur de fiches horaires
459 lines
16 KiB
JavaScript
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));
|
|
});
|