Compare commits

...

15 Commits

Author SHA1 Message Date
98df3de6c7 feat: route publique /api/public/status pour portail 2026-06-10 09:08:23 +00:00
5690e8d9ae feat: ajout route publique /api/public/status pour le portail applicatif 2026-06-10 09:07:44 +00:00
manus-admin
17d86b5000 fix: métriques monitoring via host-metrics.json (RAM/CPU réels hôte LXC) 2026-06-01 02:02:08 +02:00
manus-admin
ea7deec23c fix: RAM multi-source + montage /proc hote LXC dans docker-compose 2026-06-01 01:50:32 +02:00
manus-admin
78ba7b9f54 fix: CPU via cgroup v2 usage_usec (mesure precise conteneur LXC) + RAM formule free 2026-06-01 01:39:27 +02:00
manus-admin
664963b8f7 fix: améliorer calcul RAM (formule free: total-free-buffers-cached) + champs buffers/cached 2026-06-01 01:35:28 +02:00
manus-admin
c534a87e4c fix: remplacer nsenter par lecture directe /proc pour les métriques monitoring 2026-06-01 01:14:33 +02:00
manus-admin
3c3708e1cb fix: fermeture return() dans inventory.map InventairePage 2026-06-01 01:06:34 +02:00
manus-admin
249dee2e5a fix: correction syntaxe JSX InventairePage (fermeture map Array) 2026-06-01 01:02:34 +02:00
manus-admin
312ce634b9 feat: badge Infra (amber) pour Portail et Dashboard dans AppsPage et InventairePage 2026-06-01 00:58:04 +02:00
manus-admin
0a378d91ca fix: renommage Dashboard Production dans toutes les pages 2026-06-01 00:41:09 +02:00
manus-admin
80602797ef fix: config.js prod - ajout Formation Manager, Portail, Dashboard + URLs prod complètes pour toutes les apps 2026-06-01 00:17:58 +02:00
manus-admin
ff72cd9fc6 fix: GITEA_PASS_REC utilise GITEA_PASSWORD_RECETTE + ajout variable dans docker-compose 2026-06-01 00:05:50 +02:00
manus-admin
74558e6c63 fix: GITEA_PASSWORD=ManusGitea2026! (mot de passe prod Gitea) 2026-06-01 00:01:07 +02:00
manus-admin
ee685f0326 feat: page Inventaire, Documentation, corrections routes inventory (URLs publiques Gitea), port 3001, GITEA_PROD_INTERNAL_URL 2026-05-31 23:57:42 +02:00
14 changed files with 1175 additions and 115 deletions

View File

@@ -5,38 +5,35 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: manus-dashboard container_name: manus-dashboard
restart: unless-stopped restart: unless-stopped
privileged: true
env_file:
- .env
environment: environment:
- NODE_ENV=production NODE_ENV: production
- PORT=3001 PORT: 3001
- JWT_SECRET=${DASHBOARD_JWT_SECRET} JWT_SECRET: manus-dashboard-prod-secret-2026
- ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} ADMIN_USERNAME: adminItinova
- ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} ADMIN_PASSWORD: Itinova69!
- GITEA_URL=${GITEA_URL} GITEA_URL: https://git.santinova-soft.org
- GITEA_USERNAME=${GITEA_USERNAME} GITEA_USERNAME: manus-admin
- GITEA_PASSWORD=${GITEA_PASSWORD} GITEA_PASSWORD: ManusGitea2026!
- WEBHOOK_SECRET=${WEBHOOK_SECRET} APPS_BASE_PATH: /opt/manus-deploy/apps
- APPS_BASE_PATH=${APPS_BASE_PATH} INFRA_BASE_PATH: /opt/manus-deploy/infrastructure
- INFRA_BASE_PATH=${INFRA_BASE_PATH} WEBHOOK_SECRET: webhook-prod-secret-2026
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL}
volumes: volumes:
- /opt/manus-deploy/webhook-patched.js:/app/backend/src/webhook.js:ro
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy - /opt/manus-deploy/apps:/opt/manus-deploy/apps
- /opt/manus-deploy/infrastructure:/opt/manus-deploy/infrastructure
- /opt/manus-deploy/logs:/opt/manus-deploy/logs
# Montage /proc du VPS pour CPU (namespace partagé avec le VPS)
- /proc:/host/proc:ro
# Montage cgroup du VPS pour RAM réelle (memory.max, memory.current)
- /sys/fs/cgroup:/host/cgroup:ro
networks: networks:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" - "traefik.http.routers.dashboard.rule=Host(`dashboard.santinova-soft.org`)"
- "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.services.dashboard.loadbalancer.server.port=3001"
- "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc"
- "traefik.http.routers.manus-dashboard.priority=100"
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=web"
networks: networks:
web: web:
external: true external: true

View File

@@ -18,37 +18,41 @@ module.exports = {
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure', infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms) // Health check interval (ms)
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000, healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration - can be extended // Apps configuration
apps: [ apps: [
{ {
id: 'itinova-contacts', id: 'itinova-contacts',
name: 'Itinova Contacts', name: 'Itinova Contacts',
description: 'Application de gestion des contacts', description: 'Application de gestion des contacts Itinova',
directory: 'itinova-contacts', directory: 'itinova-contacts',
giteaRepo: 'itinova-contacts', giteaRepo: 'itinova-contacts',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://contacts.recette.santinova-soft.org', recette: 'https://contacts.recette.santinova-soft.org',
prod: null, prod: 'https://contacts.santinova-soft.org',
}, },
containerName: 'itinova-contacts', containerName: 'itinova-contacts',
healthCheckUrl: 'https://contacts.recette.santinova-soft.org', healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'ITINOVA',
status: 'production',
}, },
{ {
id: 'itinova-podcasts', id: 'itinova-podcasts',
name: 'Itinova Podcasts', name: 'Itinova Podcasts',
description: 'Application de gestion de podcasts', description: 'Gestionnaire de podcasts pour les établissements Itinova',
directory: 'itinova-podcasts', directory: 'itinova-podcasts',
giteaRepo: 'itinova-podcasts', giteaRepo: 'itinova-podcasts',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://podcasts.recette.santinova-soft.org', recette: 'https://podcasts.recette.santinova-soft.org',
prod: null, prod: 'https://podcasts.santinova-soft.org',
}, },
containerName: 'itinova-podcasts', containerName: 'itinova-podcasts',
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org', healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'ITINOVA',
status: 'production',
}, },
{ {
id: 'veille-reglementaire', id: 'veille-reglementaire',
@@ -59,56 +63,132 @@ module.exports = {
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://veille.recette.santinova-soft.org', recette: 'https://veille.recette.santinova-soft.org',
prod: null, prod: 'https://veille.santinova-soft.org',
}, },
containerName: 'veille-reglementaire', containerName: 'veille-reglementaire',
healthCheckUrl: 'https://veille.recette.santinova-soft.org', healthCheckUrl: 'https://veille.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'ITINOVA',
status: 'production',
}, },
{ {
id: 'itinova-vehicle-exchange', id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte', name: 'Itinova Gestion de Flotte',
description: 'Application de gestion de flotte de véhicules', description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements',
directory: 'itinova-vehicle-exchange', directory: 'itinova-vehicle-exchange',
giteaRepo: 'itinova-vehicle-exchange', giteaRepo: 'itinova-vehicle-exchange',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://flotte.recette.santinova-soft.org', recette: 'https://flotte.recette.santinova-soft.org',
prod: null, prod: 'https://flotte.santinova-soft.org',
}, },
containerName: 'itinova-vehicle-exchange', containerName: 'itinova-vehicle-exchange',
healthCheckUrl: 'https://flotte.recette.santinova-soft.org', healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'ITINOVA',
status: 'production',
}, },
{ {
id: 'sonum', id: 'sonum',
name: 'Sonum', name: 'SONUM',
description: 'Application Sonum', description: 'Cartographie des Solutions Numériques FEHAP',
directory: 'sonum', directory: 'sonum',
giteaRepo: 'sonum', giteaRepo: 'sonum',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://sonum.recette.santinova-soft.org', recette: 'https://sonum.recette.santinova-soft.org',
prod: null, prod: 'https://sonum.santinova-soft.org',
}, },
containerName: 'sonum', containerName: 'sonum',
healthCheckUrl: 'https://sonum.recette.santinova-soft.org', healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'SANTINOVA',
status: 'production',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
description: 'Dématérialisation des factures DSI — Import email/dossier, analyse IA, export SharePoint/PDF, validation BAP',
directory: 'demat-facturation-dsi',
giteaRepo: 'demat-facturation-dsi',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://demat-facturation.recette.santinova-soft.org',
prod: 'https://demat-facturation.santinova-soft.org',
},
containerName: 'demat-facturation-app',
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'production',
}, },
{ {
id: 'facturation-santinova', id: 'facturation-santinova',
name: 'Facturation Santinova', name: 'Facturation Santinova',
description: 'Application de gestion de la facturation', description: 'Application de gestion de la facturation Santinova',
directory: 'facturation-santinova', directory: 'facturation-santinova',
giteaRepo: 'facturation-santinova', giteaRepo: 'facturation-santinova',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://facturation.recette.santinova-soft.org', recette: 'https://facturation.recette.santinova-soft.org',
prod: null, prod: 'https://facturation.santinova-soft.org',
}, },
containerName: 'facturation-santinova-app', containerName: 'facturation-santinova-app',
healthCheckUrl: 'https://facturation.recette.santinova-soft.org', healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
port: 3001, port: 3001,
category: 'SANTINOVA',
status: 'recette',
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
description: 'Gestion des Formations Itinova',
directory: 'formation-manager-itinova',
giteaRepo: 'formation-manager-itinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://formations.recette.santinova-soft.org',
prod: 'https://formations.itinova.org',
},
containerName: 'formation-manager-itinova',
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'portail-santinova',
name: 'Portail Applicatif',
description: 'Portail applicatif Santinova — accès centralisé aux applications',
directory: 'portail-santinova',
giteaRepo: 'portail-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://portail.recette.santinova-soft.org',
prod: 'https://portail.santinova-soft.org',
},
containerName: 'portail-santinova',
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'production',
},
{
id: 'manus-dashboard',
name: 'Dashboard Production',
description: 'Dashboard de gestion de l\'infrastructure Manus',
directory: 'manus-dashboard',
giteaRepo: 'manus-dashboard',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://dashboard.recette.santinova-soft.org',
prod: 'https://dashboard.santinova-soft.org',
},
containerName: 'manus-dashboard',
healthCheckUrl: 'https://dashboard.recette.santinova-soft.org',
port: 3001,
category: 'INFRA',
status: 'production',
}, },
], ],
}; };

View File

@@ -181,67 +181,177 @@ async function restartContainer(containerName) {
function getServerMetrics() { function getServerMetrics() {
return new Promise((resolve) => { return new Promise((resolve) => {
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const fs = require("fs");
try { try {
// nsenter -t 1 -m lit les métriques réelles de l'hôte VPS (pas de l'hyperviseur) // ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte =====
const ns = "nsenter -t 1 -m --"; // Ce fichier est mis à jour toutes les 30s par /opt/manus-deploy/host-metrics.sh
// Il contient les vraies métriques de l'hôte LXC (pas du nœud physique)
// CPU : delta /proc/stat sur 200ms const hostMetricsPath = '/opt/manus-deploy/host-metrics.json';
let cpuUsage = 0; if (fs.existsSync(hostMetricsPath)) {
try {
const s1 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/);
execSync("sleep 0.2");
const s2 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/);
const idle1 = parseInt(s1[4]), total1 = s1.slice(1).reduce((a,b)=>a+parseInt(b),0);
const idle2 = parseInt(s2[4]), total2 = s2.slice(1).reduce((a,b)=>a+parseInt(b),0);
const dIdle = idle2 - idle1, dTotal = total2 - total1;
cpuUsage = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
} catch(e) {
try { try {
const cpuRaw = execSync(`${ns} top -bn1 | grep 'Cpu(s)' | awk '{print $2}'`).toString().trim(); const raw = fs.readFileSync(hostMetricsPath, 'utf8');
cpuUsage = parseFloat(cpuRaw) || 0; const hostMetrics = JSON.parse(raw);
} catch(e2) {} // Vérifier que le fichier est récent (moins de 2 minutes)
const fileAge = Date.now() - new Date(hostMetrics.timestamp).getTime();
if (fileAge < 120000 && hostMetrics.memory && hostMetrics.memory.total > 0) {
return resolve({
...hostMetrics,
timestamp: new Date().toISOString()
});
}
} catch(e) {}
} }
// RAM : cgroup de l'hôte (fiable même en environnement VPS virtualisé) // ===== Méthode 2 : Lecture directe de /proc (fallback) =====
// La RAM totale est lue depuis /sys/fs/cgroup/memory.max de l'hôte // CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC)
// La RAM utilisée est calculée via docker stats (valeurs réelles du VPS) // Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible
let memTotal = 8589934592; // 8 Go par défaut let cpuUsage = 0;
try { try {
const cgroupMax = execSync(`${ns} cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null`).toString().trim(); const ncpu = parseInt(execSync('nproc').toString().trim()) || 1;
if (cgroupMax && cgroupMax !== 'max' && !isNaN(parseInt(cgroupMax))) { const cgroupPath = '/sys/fs/cgroup/cpu.stat';
memTotal = parseInt(cgroupMax); const readUsageUsec = () => {
const stat = fs.readFileSync(cgroupPath, 'utf8');
const m = stat.match(/^usage_usec\s+(\d+)/m);
return m ? parseInt(m[1]) : null;
};
const u1 = readUsageUsec();
if (u1 !== null) {
// Méthode cgroup v2 : mesure la consommation CPU réelle du conteneur
execSync('sleep 0.5');
const u2 = readUsageUsec();
// delta en microsecondes / (500ms * ncpu) = % CPU du conteneur
const deltaUs = u2 - u1;
cpuUsage = Math.min(100, Math.round(deltaUs / (500000 * ncpu) * 100));
} else {
// Fallback : /proc/stat normalisé par nproc
const parseCpuLine = (line) => line.trim().split(/\s+/).slice(1).map(Number);
const s1 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
execSync('sleep 0.3');
const s2 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
const idle1 = s1[3], total1 = s1.reduce((a,b)=>a+b,0);
const idle2 = s2[3], total2 = s2.reduce((a,b)=>a+b,0);
const dIdle = idle2 - idle1, dTotal = total2 - total1;
const rawCpu = dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0;
// Normaliser par nproc pour éviter l'effet nœud physique sur LXC
cpuUsage = Math.round(rawCpu / ncpu);
} }
} catch(e) {} } catch(e) {}
// RAM utilisée : somme de tous les conteneurs via docker stats // RAM : lecture robuste multi-source
let memUsed = 0; // Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose)
try { // Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct)
const statsRaw = execSync("docker stats --no-stream --format '{{.MemUsage}}'").toString().trim(); // Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique
statsRaw.split('\n').forEach(line => { let memTotal = 0;
const match = line.match(/([\d.]+)(\w+)\s*\//); let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0;
if (match) {
const val = parseFloat(match[1]); // Fonction de lecture de meminfo depuis un chemin donné
const unit = match[2].toLowerCase(); const readMeminfo = (procPath) => {
if (unit === 'gib') memUsed += val * 1024 * 1024 * 1024; const raw = fs.readFileSync(procPath + '/meminfo', 'utf8');
else if (unit === 'mib') memUsed += val * 1024 * 1024; const map = {};
else if (unit === 'kib') memUsed += val * 1024; raw.split('\n').forEach(line => {
else memUsed += val; const idx = line.indexOf(':');
if (idx > 0) {
const key = line.substring(0, idx).trim();
const rest = line.substring(idx + 1).trim();
const val = parseInt(rest.split(' ')[0]);
if (Number.isFinite(val)) map[key] = val * 1024;
} }
}); });
return map;
};
try {
let memMap = null;
let sourceLabel = '';
// Priorité 1 : /host/proc monté depuis l'hôte LXC
if (fs.existsSync('/host/proc/meminfo')) {
try {
const hostMap = readMeminfo('/host/proc');
if (hostMap['MemTotal'] > 0 && hostMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = hostMap;
sourceLabel = 'host/proc';
}
} catch(e) {}
}
// Priorité 2 : /proc direct si MemTotal <= 64 Go
if (!memMap) {
try {
const procMap = readMeminfo('/proc');
if (procMap['MemTotal'] > 0 && procMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = procMap;
sourceLabel = '/proc direct';
}
} catch(e) {}
}
// Priorité 3 : Docker API MemTotal + ratio du nœud physique
if (!memMap) {
try {
const dockerInfoRaw = execSync(
'curl -s --unix-socket /var/run/docker.sock http://localhost/info',
{ timeout: 3000 }
).toString();
const dockerInfo = JSON.parse(dockerInfoRaw);
const dockerMemTotal = dockerInfo.MemTotal || 0;
if (dockerMemTotal > 0) {
// Lire le ratio d'utilisation depuis /proc du nœud physique
const nodeMap = readMeminfo('/proc');
const nodeTotal = nodeMap['MemTotal'] || 1;
const nodeAvail = nodeMap['MemAvailable'] || 0;
const usageRatio = (nodeTotal - nodeAvail) / nodeTotal;
// Appliquer ce ratio à la vraie RAM du VPS
memTotal = dockerMemTotal;
memUsed = Math.round(memTotal * usageRatio);
memFree = memTotal - memUsed;
memAvailable = memFree;
sourceLabel = 'docker-api+ratio';
}
} catch(e) {}
}
// Calculer les valeurs finales si memMap disponible
if (memMap) {
memTotal = memMap['MemTotal'] || 0;
memFree = memMap['MemFree'] || 0;
memAvailable = memMap['MemAvailable'] || 0;
memBuffers = memMap['Buffers'] || 0;
const sReclaimable = memMap['SReclaimable'] || 0;
const shmem = memMap['Shmem'] || 0;
memCached = (memMap['Cached'] || 0) + (sReclaimable > shmem ? sReclaimable - shmem : 0);
// Utiliser MemAvailable comme référence car elle inclut les caches libérables
// Identique à ce que `free` affiche dans la colonne 'disponible'
memUsed = memTotal - memAvailable;
if (memUsed < 0) memUsed = 0;
}
} catch(e) {
// Fallback ultime
try {
const m = readMeminfo('/proc');
memTotal = m['MemTotal'] || 0;
memFree = m['MemFree'] || 0;
memAvailable = m['MemAvailable'] || 0;
memUsed = memTotal - memAvailable;
} catch(e2) {}
}
// Disque : df sur /opt/manus-deploy (point de montage de l'hôte LXC)
// Utiliser /opt/manus-deploy car c'est un volume monté depuis l'hôte
let diskTotal = 0, diskUsed = 0, diskFree = 0;
try {
const diskPath = fs.existsSync('/opt/manus-deploy') ? '/opt/manus-deploy' : '/';
const diskRaw = execSync('df -B1 ' + diskPath + ' | tail -1').toString().trim().split(/\s+/);
diskTotal = parseInt(diskRaw[1]);
diskUsed = parseInt(diskRaw[2]);
diskFree = parseInt(diskRaw[3]);
} catch(e) {} } catch(e) {}
const memAvailable = memTotal - memUsed;
const memFree = memAvailable;
// Disque : df sur l'hôte // Uptime et load : utiliser /host/proc si disponible (hôte LXC réel)
const diskRaw = execSync(`${ns} df -B1 / | tail -1`).toString().trim().split(/\s+/); const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc';
const diskTotal = parseInt(diskRaw[1]); const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' ');
const diskUsed = parseInt(diskRaw[2]);
const diskFree = parseInt(diskRaw[3]);
// Uptime et load
const uptimeRaw = execSync(`${ns} cat /proc/uptime`).toString().trim().split(" ");
const uptimeSeconds = parseFloat(uptimeRaw[0]); const uptimeSeconds = parseFloat(uptimeRaw[0]);
const loadRaw = execSync(`${ns} cat /proc/loadavg`).toString().trim().split(" "); const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' ');
const load1 = parseFloat(loadRaw[0]); const load1 = parseFloat(loadRaw[0]);
const load5 = parseFloat(loadRaw[1]); const load5 = parseFloat(loadRaw[1]);
const load15 = parseFloat(loadRaw[2]); const load15 = parseFloat(loadRaw[2]);
@@ -249,9 +359,14 @@ function getServerMetrics() {
// Réseau // Réseau
let netRx = 0, netTx = 0; let netRx = 0, netTx = 0;
try { try {
const netRaw = execSync(`${ns} cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1`).toString().trim().split(/\s+/); const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev';
netRx = parseInt(netRaw[1]) || 0; const netDev = fs.readFileSync(netDevPath, 'utf8');
netTx = parseInt(netRaw[9]) || 0; const netLine = netDev.split('\n').find(l => /eth0|ens|enp/.test(l));
if (netLine) {
const parts = netLine.trim().split(/\s+/);
netRx = parseInt(parts[1]) || 0;
netTx = parseInt(parts[9]) || 0;
}
} catch(e) {} } catch(e) {}
resolve({ resolve({
@@ -261,6 +376,9 @@ function getServerMetrics() {
used: memUsed, used: memUsed,
free: memFree, free: memFree,
available: memAvailable, available: memAvailable,
buffers: memBuffers,
cached: memCached,
// usagePercent = RAM applicative réelle (hors cache disque, identique à `free`)
usagePercent: Math.round((memUsed / memTotal) * 100) usagePercent: Math.round((memUsed / memTotal) * 100)
}, },
disk: { disk: {

View File

@@ -475,3 +475,97 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
}); });
module.exports = router; module.exports = router;
// ===== INVENTAIRE DES APPLICATIONS =====
const { exec } = require('child_process');
<<<<<<< HEAD
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'https://git.recette.santinova-soft.org';
const GITEA_PROD_EXTERNAL = process.env.GITEA_PROD_INTERNAL_URL || 'http://172.18.0.9:3000';
const GITEA_PROD_PUBLIC = process.env.GITEA_PROD_PUBLIC_URL || 'https://git.santinova-soft.org';
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
const GITEA_PASS_REC = process.env.GITEA_PASSWORD_RECETTE || 'Itinova69!';
=======
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
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!';
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
const INVENTORY_APPS = [
{ id: 'itinova-contacts', name: 'Itinova Contacts', repoName: 'itinova-contacts' },
{ id: 'itinova-podcasts', name: 'Itinova Podcasts', repoName: 'itinova-podcasts' },
{ id: 'veille-reglementaire', name: 'Veille Réglementaire', repoName: 'veille-reglementaire' },
{ id: 'itinova-vehicle-exchange', name: 'Itinova Gestion de Flotte', repoName: 'itinova-vehicle-exchange' },
{ id: 'sonum', name: 'SONUM', repoName: 'sonum' },
{ id: 'demat-facturation-dsi', name: 'Démat. Facturation DSI', repoName: 'demat-facturation-dsi' },
{ id: 'facturation-santinova', name: 'Facturation Santinova', repoName: 'facturation-santinova' },
{ id: 'formation-manager-itinova', name: 'Formation Manager', repoName: 'formation-manager-itinova' },
{ id: 'portail-santinova', name: 'Portail Applicatif', repoName: 'portail-santinova' },
{ id: 'manus-dashboard', name: 'Dashboard Manus', repoName: 'manus-dashboard' },
];
<<<<<<< HEAD
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl) {
=======
function curlGitea(baseUrl, owner, repo, pass) {
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
return new Promise((resolve) => {
const auth = `${GITEA_USER_INV}:${pass}`;
const cmd = `curl -sk --max-time 6 -u "${auth}" "${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 -u "${auth}" "${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) {}
<<<<<<< HEAD
const displayUrl = publicBaseUrl || baseUrl;
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
=======
resolve({ present: true, url: `${baseUrl}/${owner}/${repo}`, version });
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
});
} catch(e) {
resolve({ present: false, url: null, version: null });
}
});
});
}
router.get('/inventory', authMiddleware, async (req, res) => {
try {
const owner = GITEA_USER_INV;
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),
<<<<<<< HEAD
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD, GITEA_PROD_PUBLIC),
=======
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
]);
return { ...app, repoRecette, repoProd };
})
);
res.json(results);
} catch (err) {
console.error('Erreur /inventory:', err.message);
res.status(500).json({ error: err.message });
}
});
;

View File

@@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: manus-dashboard container_name: manus-dashboard
restart: unless-stopped restart: unless-stopped
ports:
- "3001:3001"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3001 - PORT=3001
@@ -14,12 +16,17 @@ services:
- GITEA_URL=https://git.santinova-soft.org - GITEA_URL=https://git.santinova-soft.org
- GITEA_USERNAME=manus-admin - GITEA_USERNAME=manus-admin
- GITEA_PASSWORD=ManusGitea2026! - GITEA_PASSWORD=ManusGitea2026!
- GITEA_PASSWORD_PROD=ManusGitea2026!
- GITEA_RECETTE_URL=https://git.recette.santinova-soft.org
- GITEA_PASSWORD_RECETTE=Itinova69!
- GITEA_PROD_INTERNAL_URL=http://172.18.0.9:3000
- APPS_BASE_PATH=/opt/manus-deploy/apps - APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
- HEALTH_CHECK_INTERVAL=30000 - HEALTH_CHECK_INTERVAL=30000
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy - /opt/manus-deploy:/opt/manus-deploy
- /proc:/host/proc:ro
networks: networks:
- web - web
labels: labels:

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Manus Dashboard - Gestion des Applications</title> <title>Dashboard Production - Gestion des Applications</title>
</head> </head>
<body class="bg-dark-900 text-white"> <body class="bg-dark-900 text-white">
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

View File

@@ -6,16 +6,16 @@ import MonitoringPage from './pages/MonitoringPage';
import DeploymentsPage from './pages/DeploymentsPage'; import DeploymentsPage from './pages/DeploymentsPage';
import GiteaPage from './pages/GiteaPage'; import GiteaPage from './pages/GiteaPage';
import DockerPage from './pages/DockerPage'; import DockerPage from './pages/DockerPage';
import DocumentationPage from './pages/DocumentationPage';
import InventairePage from './pages/InventairePage';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import useWebSocket from './hooks/useWebSocket'; import useWebSocket from './hooks/useWebSocket';
import { getMe, getApps, logout as apiLogout } from './utils/api'; import { getMe, getApps, logout as apiLogout } from './utils/api';
export default function App() { export default function App() {
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState('dashboard'); const [currentPage, setCurrentPage] = useState('dashboard');
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
// Vérifier l'authentification au chargement // Vérifier l'authentification au chargement
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('dashboard_token'); const token = localStorage.getItem('dashboard_token');
@@ -34,7 +34,6 @@ export default function App() {
setLoading(false); setLoading(false);
} }
}, []); }, []);
const fetchApps = async () => { const fetchApps = async () => {
try { try {
const res = await getApps(); const res = await getApps();
@@ -43,23 +42,19 @@ export default function App() {
console.error('Erreur chargement apps:', err); console.error('Erreur chargement apps:', err);
} }
}; };
// WebSocket handler // WebSocket handler
const handleWsMessage = useCallback((data) => { const handleWsMessage = useCallback((data) => {
if (data.type === 'health_update') { if (data.type === 'health_update') {
setApps(data.data); setApps(data.data);
} }
}, []); }, []);
const { connected: wsConnected } = useWebSocket( const { connected: wsConnected } = useWebSocket(
authenticated ? handleWsMessage : null authenticated ? handleWsMessage : null
); );
const handleLogin = (data) => { const handleLogin = (data) => {
setAuthenticated(true); setAuthenticated(true);
fetchApps(); fetchApps();
}; };
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await apiLogout(); await apiLogout();
@@ -70,7 +65,6 @@ export default function App() {
setAuthenticated(false); setAuthenticated(false);
setApps([]); setApps([]);
}; };
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-dark-950"> <div className="min-h-screen flex items-center justify-center bg-dark-950">
@@ -96,11 +90,9 @@ export default function App() {
</div> </div>
); );
} }
if (!authenticated) { if (!authenticated) {
return <LoginPage onLogin={handleLogin} />; return <LoginPage onLogin={handleLogin} />;
} }
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case 'dashboard': case 'dashboard':
@@ -115,11 +107,14 @@ export default function App() {
return <DockerPage />; return <DockerPage />;
case 'monitoring': case 'monitoring':
return <MonitoringPage />; return <MonitoringPage />;
case 'documentation':
return <DocumentationPage />;
case 'inventaire':
return <InventairePage />;
default: default:
return <DashboardPage apps={apps} />; return <DashboardPage apps={apps} />;
} }
}; };
return ( return (
<div className="min-h-screen bg-dark-950"> <div className="min-h-screen bg-dark-950">
<Sidebar <Sidebar

View File

@@ -117,8 +117,19 @@ export default function AppCard({ app, commits, onRefresh }) {
const latestCommit = commits && commits.length > 0 ? commits[0] : null; const latestCommit = commits && commits.length > 0 ? commits[0] : null;
// Apps d'infrastructure : portail et dashboard
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return ( return (
<div className="card overflow-hidden"> <div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
{/* Badge infra */}
{isInfra && (
<div className="px-6 pt-3 pb-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
Infrastructure
</span>
</div>
)}
{/* Header */} {/* Header */}
<div className="p-6 border-b border-dark-700"> <div className="p-6 border-b border-dark-700">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { import { LayoutList,
Server, Server,
LayoutDashboard, LayoutDashboard,
Box, Box,
@@ -10,8 +10,8 @@ import {
Wifi, Wifi,
WifiOff, WifiOff,
Activity, Activity,
BookOpen,
} from 'lucide-react'; } from 'lucide-react';
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard }, { id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'apps', label: 'Applications', icon: Box }, { id: 'apps', label: 'Applications', icon: Box },
@@ -19,8 +19,9 @@ const navItems = [
{ id: 'gitea', label: 'Gitea', icon: GitBranch }, { id: 'gitea', label: 'Gitea', icon: GitBranch },
{ id: 'docker', label: 'Docker', icon: Container }, { id: 'docker', label: 'Docker', icon: Container },
{ id: 'monitoring', label: 'Monitoring', icon: Activity }, { id: 'monitoring', label: 'Monitoring', icon: Activity },
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
]; ];
export default function Sidebar({ export default function Sidebar({
currentPage, currentPage,
onNavigate, onNavigate,
@@ -36,24 +37,28 @@ export default function Sidebar({
<Server className="w-5 h-5 text-white" /> <Server className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-white">Manus</h1> <h1 className="text-lg font-bold text-white">Dashboard</h1>
<p className="text-xs text-gray-500">Dashboard</p> <p className="text-xs text-gray-500">Production</p>
</div> </div>
</div> </div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = currentPage === item.id; const isActive = currentPage === item.id;
const isDoc = item.id === 'documentation';
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => onNavigate(item.id)} onClick={() => onNavigate(item.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive isActive
? 'bg-primary-600/10 text-primary-400 border border-primary-500/20' ? isDoc
? 'bg-purple-600/10 text-purple-400 border border-purple-500/20'
: 'bg-primary-600/10 text-primary-400 border border-primary-500/20'
: isDoc
? 'text-purple-400/70 hover:text-purple-300 hover:bg-purple-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-dark-800' : 'text-gray-400 hover:text-gray-200 hover:bg-dark-800'
}`} }`}
> >
@@ -63,7 +68,6 @@ export default function Sidebar({
); );
})} })}
</nav> </nav>
{/* Footer */} {/* Footer */}
<div className="p-4 border-t border-dark-700 space-y-3"> <div className="p-4 border-t border-dark-700 space-y-3">
{/* WebSocket status */} {/* WebSocket status */}
@@ -80,7 +84,6 @@ export default function Sidebar({
</> </>
)} )}
</div> </div>
<button <button
onClick={onLogout} onClick={onLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors" className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"

View File

@@ -99,7 +99,7 @@ export default function DashboardPage({ apps }) {
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">
<span className="text-sm text-gray-400">Serveur</span> <span className="text-sm text-gray-400">Serveur</span>
<span className="text-sm text-gray-200"> <span className="text-sm text-gray-200">
78.138.58.109 (Recette) 180.149.196.138 (Production)
</span> </span>
</div> </div>
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">

View File

@@ -0,0 +1,420 @@
import React, { useState } from 'react';
import {
BookOpen,
Server,
GitBranch,
LayoutDashboard,
Globe,
Database,
Shield,
ArrowRight,
ChevronDown,
ChevronUp,
ExternalLink,
Activity,
Cpu,
HardDrive,
Network,
CheckCircle,
Clock,
Zap,
Package,
} from 'lucide-react';
/* ─────────────────────────────────────────────
Données de l'infrastructure
───────────────────────────────────────────── */
const RECETTE = {
label: 'RECETTE',
color: 'amber',
ip: '78.138.58.109',
os: 'Debian GNU/Linux 12',
cpu: '4 vCores',
ram: '8 Go',
disk: '147 Go',
infra: [
{
icon: GitBranch,
color: 'purple',
name: 'Gitea',
url: 'git.recette.santinova-soft.org',
desc: 'Dépôt Git — 4 repositories',
href: 'https://git.recette.santinova-soft.org',
},
{
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.recette.santinova-soft.org',
desc: 'Monitoring CPU / RAM / Disque',
href: 'https://dashboard.recette.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.recette.santinova-soft.org',
desc: 'Vitrine centralisée des applications',
href: 'https://portail.recette.santinova-soft.org',
},
{
icon: Shield,
color: 'gray',
name: 'Traefik',
url: 'Reverse Proxy SSL',
desc: "Let's Encrypt — Routage dynamique",
},
],
apps: [
{
icon: '🧾',
color: 'green',
name: 'Démat. Facturation DSI',
url: 'demat-facturation.recette.santinova-soft.org',
href: 'https://demat-facturation.recette.santinova-soft.org',
},
{
icon: '👁️',
color: 'indigo',
name: 'Veille Réglementaire',
url: 'veille.recette.santinova-soft.org',
href: 'https://veille.recette.santinova-soft.org',
},
{
icon: '🗺️',
color: 'cyan',
name: 'SONUM',
url: 'sonum.recette.santinova-soft.org',
desc: 'Cartographie solutions numériques FEHAP',
href: 'https://sonum.recette.santinova-soft.org',
},
{
icon: '👤',
color: 'pink',
name: 'Itinova Contacts',
url: 'contacts.recette.santinova-soft.org',
href: 'https://contacts.recette.santinova-soft.org',
},
{
icon: '🚗',
color: 'yellow',
name: 'Flotte Véhicules',
url: 'flotte.recette.santinova-soft.org',
href: 'https://flotte.recette.santinova-soft.org',
},
{
icon: '🎧',
color: 'orange',
name: 'Podcasts Itinova',
url: 'podcasts.recette.santinova-soft.org',
href: 'https://podcasts.recette.santinova-soft.org',
},
],
};
const PRODUCTION = {
label: 'PRODUCTION',
color: 'emerald',
ip: '180.149.196.138',
os: 'Debian GNU/Linux 12',
cpu: '8 vCores',
ram: '16 Go',
disk: '246 Go',
services: [
{
icon: GitBranch,
color: 'purple',
name: 'Gitea',
url: 'git.santinova-soft.org',
desc: 'Dépôt Git principal',
href: 'https://git.santinova-soft.org',
},
{
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.santinova-soft.org',
desc: 'Monitoring temps réel',
href: 'https://dashboard.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.santinova-soft.org',
desc: 'Vitrine applicative production',
href: 'https://portail.santinova-soft.org',
},
{
icon: Shield,
color: 'gray',
name: 'Traefik',
url: 'Reverse Proxy SSL',
desc: "Let's Encrypt — Routage dynamique",
},
],
};
const CICD_STEPS = [
{ icon: GitBranch, label: 'Push code', desc: 'Développeur pousse sur Gitea' },
{ icon: Zap, label: 'Webhook', desc: 'Gitea déclenche le webhook' },
{ icon: Package, label: 'docker compose build', desc: 'Build de la nouvelle image' },
{ icon: Server, label: 'docker compose up -d', desc: 'Redémarrage du conteneur' },
{ icon: CheckCircle, label: 'Health check', desc: 'Vérification de disponibilité' },
];
/* ─────────────────────────────────────────────
Helpers de couleur
───────────────────────────────────────────── */
const colorMap = {
purple: 'border-purple-500/40 bg-purple-500/10 text-purple-300',
blue: 'border-blue-500/40 bg-blue-500/10 text-blue-300',
teal: 'border-teal-500/40 bg-teal-500/10 text-teal-300',
gray: 'border-gray-500/40 bg-gray-500/10 text-gray-300',
green: 'border-green-500/40 bg-green-500/10 text-green-300',
indigo: 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300',
cyan: 'border-cyan-500/40 bg-cyan-500/10 text-cyan-300',
pink: 'border-pink-500/40 bg-pink-500/10 text-pink-300',
yellow: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-300',
orange: 'border-orange-500/40 bg-orange-500/10 text-orange-300',
};
/* ─────────────────────────────────────────────
Composants
───────────────────────────────────────────── */
function ServerBadge({ env }) {
const isAmber = env.color === 'amber';
const borderColor = isAmber ? 'border-amber-500/30' : 'border-emerald-500/30';
const bgColor = isAmber ? 'bg-amber-500/5' : 'bg-emerald-500/5';
return (
<div className={`flex flex-wrap gap-4 p-3 rounded-xl border ${borderColor} ${bgColor} mb-5`}>
{[
{ icon: Server, label: env.ip },
{ icon: Cpu, label: env.cpu },
{ icon: Activity, label: env.ram + ' RAM' },
{ icon: HardDrive, label: env.disk + ' Disque' },
{ icon: Network, label: env.os },
].map(({ icon: Icon, label }) => (
<div key={label} className="flex items-center gap-1.5 text-xs text-gray-400">
<Icon className="w-3.5 h-3.5 text-gray-500" />
<span>{label}</span>
</div>
))}
</div>
);
}
function ServiceCard({ item, withDb = false }) {
const cls = colorMap[item.color] || colorMap.gray;
const Icon = item.icon;
return (
<div className={`flex items-start gap-3 p-3 rounded-xl border ${cls} transition-all hover:scale-[1.01]`}>
<div className="mt-0.5 flex-shrink-0">
{typeof Icon === 'string' ? (
<span className="text-xl">{Icon}</span>
) : (
<Icon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-white">{item.name}</span>
{item.href && (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-300 transition-colors"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<p className="text-xs text-gray-400 truncate">{item.url}</p>
{item.desc && <p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>}
</div>
{withDb && (
<div className="flex items-center gap-1 text-xs text-gray-600 flex-shrink-0">
<Database className="w-3 h-3" />
<span>MySQL</span>
</div>
)}
</div>
);
}
function SectionTitle({ children, color = 'amber' }) {
const cls = color === 'amber' ? 'text-amber-400' : 'text-emerald-400';
return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>;
}
/* ─────────────────────────────────────────────
Page principale
───────────────────────────────────────────── */
export default function DocumentationPage() {
const [schemaOpen, setSchemaOpen] = useState(true);
return (
<div className="space-y-8 pb-12">
{/* ── En-tête ── */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<BookOpen className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Documentation Infrastructure</h1>
<p className="text-sm text-gray-400">Architecture de déploiement Santinova Recette &amp; Production</p>
</div>
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<Clock className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span>
</div>
</div>
{/* ── Schéma d'architecture ── */}
<div className="rounded-2xl border border-dark-700 bg-dark-900 overflow-hidden">
<button
onClick={() => setSchemaOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-dark-800 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
<Network className="w-4 h-4 text-blue-400" />
</div>
<span className="font-semibold text-white">Schéma d'Architecture</span>
</div>
{schemaOpen ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{schemaOpen && (
<div className="px-6 pb-6">
<img
src="/infra_schema_v2.png"
alt="Schéma d'architecture infrastructure Santinova"
className="w-full rounded-xl border border-dark-700 shadow-2xl"
/>
</div>
)}
</div>
{/* ── Grille Recette / CI-CD / Production ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">
{/* ── RECETTE ── */}
<div className="rounded-2xl border border-amber-500/30 bg-dark-900 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-lg shadow-amber-400/50" />
<h2 className="text-xl font-bold text-amber-400 tracking-wide">RECETTE</h2>
<span className="ml-auto text-xs text-gray-500 font-mono">78.138.58.109</span>
</div>
<ServerBadge env={RECETTE} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Infra de base */}
<div>
<SectionTitle color="amber">Infrastructure de base</SectionTitle>
<div className="space-y-2">
{RECETTE.infra.map((item) => (
<ServiceCard key={item.name} item={item} />
))}
</div>
</div>
{/* Applications */}
<div>
<SectionTitle color="amber">Applications déployées</SectionTitle>
<div className="space-y-2">
{RECETTE.apps.map((item) => (
<ServiceCard key={item.name} item={item} withDb />
))}
</div>
</div>
</div>
</div>
{/* ── CI/CD Pipeline ── */}
<div className="flex flex-col items-center justify-center gap-3 px-4 xl:px-2 py-6">
<span className="text-xs font-bold uppercase tracking-widest text-blue-400 mb-2">CI/CD</span>
{CICD_STEPS.map((step, i) => {
const Icon = step.icon;
return (
<React.Fragment key={step.label}>
<div className="flex flex-col items-center gap-1 group">
<div className="w-10 h-10 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center group-hover:bg-blue-600/30 transition-colors">
<Icon className="w-5 h-5 text-blue-400" />
</div>
<span className="text-xs text-gray-400 text-center max-w-[80px] leading-tight">{step.label}</span>
</div>
{i < CICD_STEPS.length - 1 && (
<div className="w-px h-4 bg-gradient-to-b from-blue-500/50 to-blue-500/10" />
)}
</React.Fragment>
);
})}
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-xl bg-blue-600/10 border border-blue-500/20">
<ArrowRight className="w-4 h-4 text-blue-400" />
<span className="text-xs text-blue-300 font-semibold whitespace-nowrap">Promotion → PROD</span>
</div>
</div>
{/* ── PRODUCTION ── */}
<div className="rounded-2xl border border-emerald-500/30 bg-dark-900 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 rounded-full bg-emerald-400 shadow-lg shadow-emerald-400/50" />
<h2 className="text-xl font-bold text-emerald-400 tracking-wide">PRODUCTION</h2>
<span className="ml-auto text-xs text-gray-500 font-mono">180.149.196.138</span>
</div>
<ServerBadge env={PRODUCTION} />
<SectionTitle color="emerald">Services actifs</SectionTitle>
<div className="space-y-2 mb-4">
{PRODUCTION.services.map((item) => (
<ServiceCard key={item.name} item={item} />
))}
</div>
{/* Carte "Applications à venir" */}
<div className="flex items-center gap-3 p-3 rounded-xl border border-dashed border-gray-600/40 bg-gray-500/5">
<Package className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm font-semibold text-gray-400">Applications à venir</p>
<p className="text-xs text-gray-600">Déploiement progressif via CI/CD</p>
</div>
</div>
{/* Badge metrics-collector */}
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/20">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">metrics-collector · systemd · actif</span>
</div>
</div>
</div>
{/* ── Légende technologique ── */}
<div className="rounded-2xl border border-dark-700 bg-dark-900 p-6">
<h3 className="text-sm font-bold text-white mb-4">Stack Technologique</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ emoji: '🐳', label: 'Docker', desc: 'Conteneurisation' },
{ emoji: '🔒', label: 'HTTPS', desc: "Let's Encrypt SSL" },
{ emoji: '🗄', label: 'MySQL 8.0', desc: 'Base de données' },
{ emoji: '🔀', label: 'Traefik', desc: 'Reverse Proxy' },
].map((t) => (
<div
key={t.label}
className="flex items-center gap-3 p-3 rounded-xl bg-dark-800 border border-dark-700"
>
<span className="text-2xl">{t.emoji}</span>
<div>
<p className="text-sm font-semibold text-white">{t.label}</p>
<p className="text-xs text-gray-500">{t.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle } from 'lucide-react';
import api from '../utils/api';
const APPS_CONFIG = [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
repoName: 'itinova-contacts',
urlRecette: 'https://contacts.recette.santinova-soft.org',
urlProd: 'https://contacts.santinova-soft.org',
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
repoName: 'itinova-podcasts',
urlRecette: 'https://podcasts.recette.santinova-soft.org',
urlProd: 'https://podcasts.santinova-soft.org',
},
{
id: 'veille-reglementaire',
name: 'Veille Réglementaire',
repoName: 'veille-reglementaire',
urlRecette: 'https://veille.recette.santinova-soft.org',
urlProd: 'https://veille.santinova-soft.org',
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
repoName: 'itinova-vehicle-exchange',
urlRecette: 'https://flotte.recette.santinova-soft.org',
urlProd: 'https://flotte.santinova-soft.org',
},
{
id: 'sonum',
name: 'SONUM',
repoName: 'sonum',
urlRecette: 'https://sonum.recette.santinova-soft.org',
urlProd: 'https://sonum.santinova-soft.org',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
repoName: 'demat-facturation-dsi',
urlRecette: 'https://demat-facturation.recette.santinova-soft.org',
urlProd: 'https://demat-facturation.santinova-soft.org',
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
repoName: 'facturation-santinova',
urlRecette: 'https://facturation.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
repoName: 'formation-manager-itinova',
urlRecette: 'https://formation.recette.santinova-soft.org',
urlProd: 'https://formations.itinova.org',
},
{
id: 'portail-santinova',
name: 'Portail Applicatif',
repoName: 'portail-santinova',
urlRecette: 'https://portail.recette.santinova-soft.org',
urlProd: 'https://portail.santinova-soft.org',
},
{
id: 'manus-dashboard',
name: 'Dashboard Production',
repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org',
},
];
function VersionBadge({ version, loading }) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
<Tag className="w-3 h-3" />
Chargement...
</span>
);
}
if (!version) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
<AlertCircle className="w-3 h-3" />
N/A
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-blue-900/40 text-blue-300 border border-blue-700/40">
<Tag className="w-3 h-3" />
{version}
</span>
);
}
function RepoBadge({ present, url, label }) {
if (!present) {
return (
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-gray-500 text-xs">Absent</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-400 hover:text-primary-300 hover:underline flex items-center gap-1 truncate max-w-[180px]"
title={url}
>
{label}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
</div>
);
}
export default function InventairePage() {
const [inventory, setInventory] = useState([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(null);
const fetchInventory = async () => {
setLoading(true);
try {
const res = await api.get('/inventory');
setInventory(res.data);
setLastUpdate(new Date());
} catch (err) {
console.error('Erreur chargement inventaire:', err);
// Fallback : construire l'inventaire depuis la config statique
const fallback = APPS_CONFIG.map((app) => ({
...app,
repoRecette: { present: false, url: null, version: null },
repoProd: { present: false, url: null, version: null },
}));
setInventory(fallback);
} finally {
setLoading(false);
}
};
// Auto-refresh toutes les 5 minutes
useEffect(() => {
fetchInventory();
const interval = setInterval(fetchInventory, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<Table className="w-7 h-7 text-emerald-400" />
Inventaire des Applications
</h2>
<p className="text-gray-400 mt-1">
État des dépôts Git et versions déployées par environnement
</p>
</div>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-gray-500">
Mis à jour : {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
<button
onClick={fetchInventory}
disabled={loading}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</button>
</div>
</div>
{/* Légende */}
<div className="flex items-center gap-6 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500" />
Dépôt présent
</div>
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500" />
Dépôt absent
</div>
<div className="flex items-center gap-1.5">
<Tag className="w-4 h-4 text-blue-400" />
Version (dernier commit)
</div>
</div>
{/* Tableau */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-700">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
Application
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider" colSpan={2}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Recette
</div>
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-green-400 uppercase tracking-wider" colSpan={2}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Production
</div>
</th>
</tr>
<tr className="border-b border-dark-700 bg-dark-800/50">
<th className="px-4 py-2"></th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{loading && inventory.length === 0
? APPS_CONFIG.map((app) => (
<tr key={app.id} className="hover:bg-dark-800/30 transition-colors">
<td className="px-4 py-3">
<span className="text-sm font-medium text-white">{app.name}</span>
</td>
{[...Array(4)].map((_, i) => (
<td key={i} className="px-3 py-3 text-center">
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
</td>
))}
</tr>
))
: inventory.map((app) => {
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return (
<tr key={app.id} className={`hover:bg-dark-800/30 transition-colors${isInfra ? ' bg-amber-950/10 border-l-2 border-amber-500/40' : ''}`}>
<td className="px-4 py-3">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isInfra ? 'text-amber-300' : 'text-white'}`}>{app.name}</span>
{isInfra && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 border border-amber-500/25 font-semibold">Infra</span>
)}
</div>
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
</div>
</td>
{/* Recette - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoRecette?.present}
url={app.repoRecette?.url}
label={`git.recette/…/${app.repoName}`}
/>
</td>
{/* Recette - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoRecette?.version}
loading={loading}
/>
</td>
{/* Prod - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoProd?.present}
url={app.repoProd?.url}
label={`git.santinova/…/${app.repoName}`}
/>
</td>
{/* Prod - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoProd?.version}
loading={loading}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Stats */}
{!loading && inventory.length > 0 && (
<div className="grid grid-cols-4 gap-4">
<div className="card p-4 text-center border border-dark-700">
<div className="text-2xl font-bold text-white">{inventory.length}</div>
<div className="text-xs text-gray-400 mt-1">Applications totales</div>
</div>
<div className="card p-4 text-center border border-orange-700/30">
<div className="text-2xl font-bold text-orange-400">
{inventory.filter((a) => a.repoRecette?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Dépôts Recette</div>
</div>
<div className="card p-4 text-center border border-green-700/30">
<div className="text-2xl font-bold text-green-400">
{inventory.filter((a) => a.repoProd?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Dépôts Production</div>
</div>
<div className="card p-4 text-center border border-blue-700/30">
<div className="text-2xl font-bold text-blue-400">
{inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Déployés sur les 2 envs</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -34,7 +34,7 @@ export default function LoginPage({ onLogin }) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
<Server className="w-8 h-8 text-white" /> <Server className="w-8 h-8 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Manus Dashboard</h1> <h1 className="text-2xl font-bold text-white">Dashboard Production</h1>
<p className="text-gray-400 mt-2">Gestion des applications</p> <p className="text-gray-400 mt-2">Gestion des applications</p>
</div> </div>
@@ -130,7 +130,7 @@ export default function LoginPage({ onLogin }) {
</div> </div>
<p className="text-center text-gray-600 text-sm mt-6"> <p className="text-center text-gray-600 text-sm mt-6">
Santinova Soft &mdash; Serveur de recette Santinova Soft &mdash; Serveur de production
</p> </p>
</div> </div>
</div> </div>