602 lines
20 KiB
JavaScript
602 lines
20 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { authenticate, authMiddleware } = require('./auth');
|
|
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
|
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
|
|
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
|
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
|
const config = require('./config');
|
|
|
|
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
|
cleanupOrphanedDeployments();
|
|
|
|
const router = express.Router();
|
|
|
|
// Configuration multer pour l'upload de fichiers ZIP
|
|
const uploadDir = '/tmp/dashboard-uploads';
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
const upload = multer({
|
|
dest: uploadDir,
|
|
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max
|
|
fileFilter: (req, file, cb) => {
|
|
if (file.mimetype === 'application/zip' ||
|
|
file.mimetype === 'application/x-zip-compressed' ||
|
|
file.mimetype === 'application/octet-stream' ||
|
|
file.originalname.endsWith('.zip')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Seuls les fichiers ZIP sont acceptés'), false);
|
|
}
|
|
},
|
|
});
|
|
|
|
// ============ AUTH ROUTES ============
|
|
|
|
router.post('/auth/login', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' });
|
|
}
|
|
const token = await authenticate(username, password);
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Identifiants invalides' });
|
|
}
|
|
// Set cookie
|
|
res.cookie('dashboard_token', token, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 24 * 60 * 60 * 1000, // 24h
|
|
});
|
|
res.json({ token, username });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/auth/logout', (req, res) => {
|
|
res.clearCookie('dashboard_token');
|
|
res.json({ message: 'Déconnecté' });
|
|
});
|
|
|
|
router.get('/auth/me', authMiddleware, (req, res) => {
|
|
res.json({ username: req.user.username, role: req.user.role });
|
|
});
|
|
|
|
// ============ APPS ROUTES ============
|
|
|
|
// Liste des applications avec leur statut
|
|
router.get('/apps', authMiddleware, async (req, res) => {
|
|
try {
|
|
const statuses = getAllStatuses();
|
|
if (statuses.length === 0) {
|
|
// Première requête, lancer un check
|
|
const results = await checkAllApps();
|
|
return res.json(results);
|
|
}
|
|
res.json(statuses);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Détail d'une application
|
|
router.get('/apps/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
|
if (!appConfig) {
|
|
return res.status(404).json({ error: 'Application non trouvée' });
|
|
}
|
|
|
|
const status = getAppStatus(appConfig.id);
|
|
const containerInfo = await getContainerInfo(appConfig.containerName);
|
|
|
|
// Récupérer les infos Gitea
|
|
let giteaInfo = null;
|
|
let commits = [];
|
|
try {
|
|
giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo);
|
|
commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10);
|
|
} catch (e) {
|
|
// Gitea peut ne pas être disponible
|
|
}
|
|
|
|
res.json({
|
|
...status,
|
|
container: containerInfo,
|
|
gitea: giteaInfo
|
|
? {
|
|
fullName: giteaInfo.full_name,
|
|
description: giteaInfo.description,
|
|
defaultBranch: giteaInfo.default_branch,
|
|
updatedAt: giteaInfo.updated_at,
|
|
cloneUrl: giteaInfo.clone_url,
|
|
htmlUrl: giteaInfo.html_url,
|
|
}
|
|
: null,
|
|
commits,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Forcer un health check
|
|
router.post('/apps/:id/check', authMiddleware, async (req, res) => {
|
|
try {
|
|
const results = await checkAllApps();
|
|
const result = results.find((r) => r.id === req.params.id);
|
|
res.json(result || { error: 'Application non trouvée' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Verrou anti-concurrence par application
|
|
const deployLocks = new Map();
|
|
const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max
|
|
|
|
// Nettoyage automatique des verrous bloqués toutes les minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [appId, lockTime] of deployLocks.entries()) {
|
|
if (now - lockTime > DEPLOY_TIMEOUT_MS) {
|
|
deployLocks.delete(appId);
|
|
const logs = getDeploymentLogs(appId);
|
|
logs.filter(l => l.status === 'running').forEach(l => {
|
|
l.status = 'failed';
|
|
l.message = 'Déploiement interrompu automatiquement (timeout 10 min)';
|
|
});
|
|
}
|
|
}
|
|
}, 60 * 1000);
|
|
|
|
// Redéployer une application
|
|
router.post('/apps/:id/deploy', authMiddleware, async (req, res) => {
|
|
try {
|
|
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
|
if (!appConfig) {
|
|
return res.status(404).json({ error: 'Application non trouvée' });
|
|
}
|
|
|
|
// Vérifier si un déploiement est déjà en cours pour cette app
|
|
if (deployLocks.has(appConfig.id)) {
|
|
const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000);
|
|
return res.status(409).json({
|
|
error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`,
|
|
status: 'running'
|
|
});
|
|
}
|
|
|
|
// Poser le verrou
|
|
deployLocks.set(appConfig.id, Date.now());
|
|
|
|
// Ajouter un log de début et récupérer son id pour le mettre à jour plus tard
|
|
const deployEntry = addDeploymentLog(appConfig.id, {
|
|
status: 'running',
|
|
message: `Déploiement de ${appConfig.name} en cours...`,
|
|
logs: '',
|
|
user: req.user.username,
|
|
});
|
|
|
|
// Lancer le redéploiement en arrière-plan
|
|
res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' });
|
|
|
|
// Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed
|
|
const timeoutHandle = setTimeout(() => {
|
|
if (deployLocks.has(appConfig.id)) {
|
|
deployLocks.delete(appConfig.id);
|
|
updateDeploymentLog(deployEntry.id, {
|
|
status: 'failed',
|
|
message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`,
|
|
});
|
|
}
|
|
}, DEPLOY_TIMEOUT_MS);
|
|
|
|
try {
|
|
// Exécuter le redéploiement
|
|
const result = await redeployApp(appConfig);
|
|
clearTimeout(timeoutHandle);
|
|
|
|
// Mettre à jour l'entrée existante (pas de doublon "running" + "success")
|
|
updateDeploymentLog(deployEntry.id, {
|
|
status: result.success ? 'success' : 'failed',
|
|
message: result.success
|
|
? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s`
|
|
: `Redéploiement de ${appConfig.name} échoué`,
|
|
logs: result.logs,
|
|
duration: result.duration,
|
|
});
|
|
|
|
// Relancer un health check après le déploiement
|
|
setTimeout(() => checkAllApps(), 5000);
|
|
} finally {
|
|
// Toujours libérer le verrou
|
|
deployLocks.delete(appConfig.id);
|
|
}
|
|
} catch (err) {
|
|
deployLocks.delete(req.params.id);
|
|
addDeploymentLog(req.params.id, {
|
|
status: 'failed',
|
|
message: `Erreur: ${err.message}`,
|
|
logs: '',
|
|
user: req.user.username,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Logs d'un conteneur
|
|
router.get('/apps/:id/logs', authMiddleware, async (req, res) => {
|
|
try {
|
|
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
|
if (!appConfig) {
|
|
return res.status(404).json({ error: 'Application non trouvée' });
|
|
}
|
|
const tail = parseInt(req.query.tail) || 100;
|
|
const logs = await getContainerLogs(appConfig.containerName, tail);
|
|
res.json({ logs });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ CREATE APP ROUTE ============
|
|
|
|
// Récupérer les stacks disponibles
|
|
router.get('/apps-config/stacks', authMiddleware, (req, res) => {
|
|
res.json(getAvailableStacks());
|
|
});
|
|
|
|
// Créer une nouvelle application
|
|
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
|
|
try {
|
|
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
|
|
|
|
// Validation
|
|
if (!name || !subdomain || !stack || !sourceType) {
|
|
return res.status(400).json({
|
|
error: 'Champs requis manquants: name, subdomain, stack, sourceType',
|
|
});
|
|
}
|
|
|
|
// Vérifier que le subdomain est valide
|
|
const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
if (!subdomainRegex.test(subdomain)) {
|
|
return res.status(400).json({
|
|
error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets',
|
|
});
|
|
}
|
|
|
|
// Vérifier que l'app n'existe pas déjà
|
|
const existingApp = config.apps.find((a) => a.id === subdomain);
|
|
if (existingApp) {
|
|
return res.status(409).json({
|
|
error: `Une application avec l'identifiant "${subdomain}" existe déjà`,
|
|
});
|
|
}
|
|
|
|
// Vérifier le fichier ZIP si sourceType === 'zip'
|
|
if (sourceType === 'zip' && !req.file) {
|
|
return res.status(400).json({
|
|
error: 'Un fichier ZIP est requis pour le type de source "zip"',
|
|
});
|
|
}
|
|
|
|
// Vérifier l'URL Gitea si sourceType === 'gitea'
|
|
if (sourceType === 'gitea' && !giteaRepoUrl) {
|
|
return res.status(400).json({
|
|
error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"',
|
|
});
|
|
}
|
|
|
|
// Log de début de création et récupérer l'id pour mise à jour ultérieure
|
|
const logLines = [];
|
|
const createEntry = addDeploymentLog(subdomain, {
|
|
status: 'running',
|
|
message: `Création de l'application ${name} en cours...`,
|
|
logs: '',
|
|
user: req.user.username,
|
|
});
|
|
|
|
// Répondre immédiatement
|
|
res.json({
|
|
message: `Création de l'application ${name} lancée`,
|
|
status: 'running',
|
|
appId: subdomain,
|
|
});
|
|
|
|
// Lancer la création en arrière-plan
|
|
try {
|
|
const result = await createApplication(
|
|
{
|
|
name,
|
|
description,
|
|
subdomain,
|
|
stack,
|
|
port: port ? parseInt(port) : null,
|
|
needsDb: needsDb === 'true' || needsDb === true,
|
|
dbType: dbType || 'mysql',
|
|
sourceType,
|
|
giteaRepoUrl,
|
|
zipFilePath: req.file ? req.file.path : null,
|
|
},
|
|
(line) => logLines.push(line)
|
|
);
|
|
|
|
updateDeploymentLog(createEntry.id, {
|
|
status: 'success',
|
|
message: `Application ${name} créée et déployée avec succès`,
|
|
logs: logLines.join('\n'),
|
|
});
|
|
|
|
// Relancer un health check
|
|
setTimeout(() => checkAllApps(), 10000);
|
|
} catch (createErr) {
|
|
updateDeploymentLog(createEntry.id, {
|
|
status: 'failed',
|
|
message: `Création de ${name} échouée: ${createErr.message}`,
|
|
logs: logLines.join('\n'),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ DEPLOYMENT LOGS ============
|
|
|
|
router.get('/deployments', authMiddleware, (req, res) => {
|
|
const appId = req.query.appId || null;
|
|
res.json(getDeploymentLogs(appId));
|
|
});
|
|
|
|
// ============ GITEA ROUTES ============
|
|
|
|
router.get('/gitea/repos', authMiddleware, async (req, res) => {
|
|
try {
|
|
const repos = await listRepos();
|
|
res.json(
|
|
repos.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
fullName: r.full_name,
|
|
description: r.description,
|
|
private: r.private,
|
|
htmlUrl: r.html_url,
|
|
cloneUrl: r.clone_url,
|
|
updatedAt: r.updated_at,
|
|
language: r.language,
|
|
size: r.size,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => {
|
|
try {
|
|
const commits = await getCommits(req.params.owner, req.params.repo, 20);
|
|
res.json(commits);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => {
|
|
try {
|
|
const branches = await getBranches(req.params.owner, req.params.repo);
|
|
res.json(branches);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
|
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
|
router.get('/public/status', async (req, res) => {
|
|
try {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
const statuses = getAllStatuses();
|
|
const publicStatus = statuses.map((app) => ({
|
|
id: app.id,
|
|
name: app.name,
|
|
status: app.status, // 'online' | 'offline' | 'error' | 'unknown'
|
|
containerRunning: app.container ? app.container.running : false,
|
|
}));
|
|
res.json(publicStatus);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ PUBLIC APPS ROUTE (sans authentification) ============
|
|
// Utilisé par le portail applicatif pour construire les tuiles dynamiquement
|
|
// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles
|
|
router.get('/public/apps', (req, res) => {
|
|
try {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
// getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué
|
|
// On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse
|
|
const statuses = getAllStatuses();
|
|
const publicApps = config.apps
|
|
.filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard)
|
|
.map((a) => {
|
|
const status = statuses.find((s) => s.id === a.id);
|
|
return {
|
|
id: a.id,
|
|
name: a.name,
|
|
category: a.category || 'SANTINOVA',
|
|
image: a.image || ('images/' + a.id + '.png'),
|
|
urls: a.urls || {},
|
|
status: status ? status.status : 'unknown',
|
|
containerRunning: status && status.container ? status.container.running : false,
|
|
};
|
|
});
|
|
res.json(publicApps);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ DOCKER ROUTES ============
|
|
|
|
router.get('/docker/containers', authMiddleware, async (req, res) => {
|
|
try {
|
|
const containers = await listContainers();
|
|
res.json(containers);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ SYSTEM INFO ============
|
|
|
|
router.get('/system/info', authMiddleware, async (req, res) => {
|
|
try {
|
|
const containers = await listContainers();
|
|
res.json({
|
|
totalApps: config.apps.length,
|
|
totalContainers: containers.length,
|
|
runningContainers: containers.filter((c) => c.state === 'running').length,
|
|
giteaUrl: config.gitea.url,
|
|
serverTime: new Date().toISOString(),
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
|
|
// ============ CONTAINER CONTROL (start/stop/restart) ============
|
|
router.post('/apps/:id/start', authMiddleware, async (req, res) => {
|
|
try {
|
|
const app = config.apps.find((a) => a.id === req.params.id);
|
|
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
|
const containerName = app.containerName || app.id;
|
|
const result = await startContainer(containerName, app.id);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/apps/:id/stop', authMiddleware, async (req, res) => {
|
|
try {
|
|
const app = config.apps.find((a) => a.id === req.params.id);
|
|
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
|
const containerName = app.containerName || app.id;
|
|
const result = await stopContainer(containerName);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/apps/:id/restart', authMiddleware, async (req, res) => {
|
|
try {
|
|
const app = config.apps.find((a) => a.id === req.params.id);
|
|
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
|
const containerName = app.containerName || app.id;
|
|
const result = await restartContainer(containerName);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ============ SERVER METRICS ============
|
|
router.get('/system/metrics', authMiddleware, async (req, res) => {
|
|
try {
|
|
const metrics = await getServerMetrics();
|
|
res.json(metrics);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
// ===== INVENTAIRE DES APPLICATIONS =====
|
|
const { exec } = require('child_process');
|
|
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
|
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
|
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
|
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
|
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
|
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
|
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
|
|
|
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
|
function getInventoryApps() {
|
|
return config.apps.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
repoName: a.giteaRepo || a.id,
|
|
}));
|
|
}
|
|
|
|
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
|
return new Promise((resolve) => {
|
|
const authHeader = token
|
|
? `-H "Authorization: token ${token}"`
|
|
: `-u "${GITEA_USER_INV}:${pass}"`;
|
|
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
|
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
|
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
|
try {
|
|
const data = JSON.parse(stdout);
|
|
if (!data.id) return resolve({ present: false, url: null, version: null });
|
|
// Récupérer le dernier commit
|
|
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
|
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
|
let version = null;
|
|
try {
|
|
const commits = JSON.parse(stdout2 || '[]');
|
|
if (commits.length > 0) {
|
|
const c = commits[0];
|
|
const sha = c.sha ? c.sha.substring(0, 7) : null;
|
|
const date = c.commit && c.commit.author && c.commit.author.date
|
|
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
|
|
version = sha && date ? `${sha} (${date})` : sha;
|
|
}
|
|
} catch(e) {}
|
|
const displayUrl = publicBaseUrl || baseUrl;
|
|
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
|
});
|
|
} catch(e) {
|
|
resolve({ present: false, url: null, version: null });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
router.get('/inventory', authMiddleware, async (req, res) => {
|
|
try {
|
|
const owner = GITEA_USER_INV;
|
|
const INVENTORY_APPS = getInventoryApps();
|
|
const results = await Promise.all(
|
|
INVENTORY_APPS.map(async (app) => {
|
|
const [repoRecette, repoProd] = await Promise.all([
|
|
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
|
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
|
]);
|
|
return { ...app, repoRecette, repoProd };
|
|
})
|
|
);
|
|
res.json(results);
|
|
} catch (err) {
|
|
console.error('Erreur /inventory:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
;
|