Compare commits

...

15 Commits

Author SHA1 Message Date
Manus Agent
21c5457403 feat: ajout falc-generator dans config.js inventaire 2026-07-11 03:47:57 +02:00
Manus Agent
f833022f7a feat: terminal SSH, inventaire synchronisation, token Gitea, pilotage-masse-salariale
- Ajout terminal SSH intégré (xterm.js + WebSocket + ssh2)
- Colonne Synchronisation dans l inventaire (recette vs prod)
- Compteurs statistiques déplacés en haut de l inventaire
- Authentification Gitea par token API (plus d auth basique)
- URL Gitea interne stable (http://gitea:3000)
- Ajout pilotage-masse-salariale dans config.js et inventaire
- Correction lien git.recette dans GiteaPage
- Mise à jour docker-compose.yml (GITEA_TOKEN, GITEA_RECETTE_URL stable)
2026-07-11 03:19:45 +02:00
Manus
3a25eca200 feat: ajout route publique /api/public/status pour le portail applicatif 2026-06-10 05:02:53 -04:00
manus-admin
7b464c3991 fix: métriques monitoring via host-metrics.json (RAM/CPU réels hôte LXC) 2026-06-01 02:00:57 +02:00
manus-admin
ae14e23c3b fix: RAM multi-source (host/proc > proc direct > docker-api+ratio) 2026-06-01 01:50:32 +02:00
manus-admin
e6a3a0671c fix: CPU via cgroup v2 usage_usec (mesure precise conteneur LXC) + RAM formule free 2026-06-01 01:39:20 +02:00
manus-admin
2b149a1f81 fix: améliorer calcul RAM (formule free: total-free-buffers-cached) + champs buffers/cached 2026-06-01 01:35:27 +02:00
manus-admin
ed57d4af73 fix: remplacer nsenter par lecture directe /proc pour les métriques monitoring 2026-06-01 01:14:33 +02:00
manus-admin
6bae1d569d fix: fermeture return() dans inventory.map InventairePage 2026-06-01 01:06:33 +02:00
manus-admin
b40300cfc6 fix: correction syntaxe JSX InventairePage (fermeture map Array) 2026-06-01 01:02:25 +02:00
manus-admin
ea09f631d1 feat: badge Infra (amber) pour Portail et Dashboard dans AppsPage et InventairePage 2026-06-01 00:57:45 +02:00
manus-admin
c69bf3b522 fix: renommage Dashboard Recette + URL Gitea recette corrigée dans toutes les pages 2026-06-01 00:37:35 +02:00
manus-admin
04be6920a1 fix: config.js recette - ajout Formation Manager, Portail, Dashboard + URLs prod complètes pour toutes les apps 2026-06-01 00:17:22 +02:00
manus-admin
355e2a85e2 fix: URLs Gitea correctes dans inventaire (recette/prod séparées), GITEA_RECETTE_PUBLIC 2026-05-31 23:57:25 +02:00
manus-admin
0781c76931 feat: page Inventaire Applications, Documentation Infra, corrections monitoring 2026-05-31 23:35:01 +02:00
21 changed files with 1987 additions and 104 deletions

View File

@@ -29,7 +29,7 @@ services:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
- "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"

View File

@@ -21,7 +21,8 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"adm-zip": "^0.5.10" "adm-zip": "^0.5.10",
"ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.0" "nodemon": "^3.1.0"

View File

@@ -9,46 +9,51 @@ module.exports = {
}, },
// Gitea // Gitea
gitea: { gitea: {
url: process.env.GITEA_URL || 'https://git.santinova-soft.org', url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin', username: process.env.GITEA_USERNAME || 'manus-admin',
password: process.env.GITEA_PASSWORD || 'ManusGitea2026!', password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null,
}, },
// Applications config // Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
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 +64,183 @@ 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-recette',
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: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale',
description: 'Application de pilotage de la masse salariale Itinova',
directory: 'pilotage-masse-salariale',
giteaRepo: 'pilotage-masse-salariale',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://pilotage-ms.recette.santinova-soft.org',
prod: 'https://pilotage-ms.santinova-soft.org',
},
containerName: 'pilotage-masse-salariale-app',
healthCheckUrl: 'https://pilotage-ms.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'recette',
}, },
{ {
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 Recette',
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',
},
{
id: 'itinova-budget-si',
name: 'Gestion Budget Informatique',
description: 'Application de gestion du budget informatique DSI Itinova',
directory: 'itinova-budget-si',
giteaRepo: 'itinova-budget-si',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://budget-si.recette.santinova-soft.org',
prod: 'https://budget-si.santinova-soft.org',
},
containerName: 'itinova-budget-si-app',
healthCheckUrl: 'https://budget-si.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'recette',
},
{
id: 'falc-generator',
name: 'FALC Generator',
description: 'Générateur de documents FALC (Facile À Lire et à Comprendre)',
directory: 'falc-generator',
giteaRepo: 'falc-generator',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://falc.recette.santinova-soft.org',
prod: 'https://falc.santinova-soft.org',
},
containerName: 'falc-generator',
healthCheckUrl: 'https://falc.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
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

@@ -3,12 +3,17 @@ const https = require('https');
const config = require('./config'); const config = require('./config');
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire) // Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
// Utiliser le token API si disponible, sinon auth basique
const giteaClientHeaders = config.gitea.token
? { 'Authorization': `token ${config.gitea.token}` }
: {};
const giteaClient = axios.create({ const giteaClient = axios.create({
baseURL: `${config.gitea.url}/api/v1`, baseURL: `${config.gitea.url}/api/v1`,
auth: { ...(config.gitea.token
username: config.gitea.username, ? { headers: giteaClientHeaders }
password: config.gitea.password, : { auth: { username: config.gitea.username, password: config.gitea.password } }
}, ),
httpsAgent: new https.Agent({ rejectUnauthorized: false }), httpsAgent: new https.Agent({ rejectUnauthorized: false }),
timeout: 10000, timeout: 10000,
}); });

View File

@@ -15,12 +15,14 @@ const webhookRoutes = require('./webhook');
const { checkAllApps, getAllStatuses } = require('./healthcheck'); const { checkAllApps, getAllStatuses } = require('./healthcheck');
const { getCommits } = require('./gitea'); const { getCommits } = require('./gitea');
const { initDynamicApps } = require('./app-creator'); const { initDynamicApps } = require('./app-creator');
const { initSSHWebSocket } = require('./ssh');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
// WebSocket server pour les mises à jour en temps réel // WebSocket server pour les mises à jour en temps réel
const wss = new WebSocket.Server({ server, path: '/ws' }); const wss = new WebSocket.Server({ server, path: '/ws' });
initSSHWebSocket(server);
// Middleware // Middleware
app.use(helmet({ app.use(helmet({

View File

@@ -398,6 +398,25 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
} }
}); });
// ============ 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 });
}
});
// ============ DOCKER ROUTES ============ // ============ DOCKER ROUTES ============
router.get('/docker/containers', authMiddleware, async (req, res) => { router.get('/docker/containers', authMiddleware, async (req, res) => {
@@ -475,3 +494,84 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
}); });
module.exports = router; 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_TOKEN || null;
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: 'pilotage-masse-salariale', name: 'Pilotage Masse Salariale', repoName: 'pilotage-masse-salariale' },
{ id: 'itinova-budget-si', name: 'Gestion Budget Informatique', repoName: 'itinova-budget-si' },
{ id: 'falc-generator', name: 'FALC Generator', repoName: 'falc-generator' },
{ id: 'portail-santinova', name: 'Portail Applicatif', repoName: 'portail-santinova' },
{ id: 'manus-dashboard', name: 'Dashboard Manus', repoName: 'manus-dashboard' },
];
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 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 });
}
});
;

179
src/backend/src/ssh.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* Module SSH Terminal - Gestion des connexions SSH via WebSocket
* Utilise la bibliothèque ssh2 pour établir des connexions SSH
*/
const { Client } = require('ssh2');
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const config = require('./config');
/**
* Initialise le serveur WebSocket SSH sur /ws-ssh
* @param {http.Server} server - Le serveur HTTP Express
*/
function initSSHWebSocket(server) {
const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' });
wssSsh.on('connection', (ws, req) => {
// Vérifier l'authentification via le token dans l'URL
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
if (!token) {
ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' }));
ws.close();
return;
}
try {
jwt.verify(token, config.jwtSecret);
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' }));
ws.close();
return;
}
console.log('Nouvelle connexion WebSocket SSH');
let sshClient = null;
let sshStream = null;
let connected = false;
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
switch (msg.type) {
case 'connect':
// Établir la connexion SSH
handleConnect(ws, msg, (client, stream) => {
sshClient = client;
sshStream = stream;
connected = true;
});
break;
case 'input':
// Envoyer des données au terminal SSH
if (sshStream && connected) {
sshStream.write(msg.data);
}
break;
case 'resize':
// Redimensionner le terminal
if (sshStream && connected) {
sshStream.setWindow(msg.rows, msg.cols, 0, 0);
}
break;
case 'disconnect':
// Fermer la connexion SSH
if (sshClient) {
sshClient.end();
}
break;
default:
console.warn('Type de message SSH inconnu:', msg.type);
}
} catch (err) {
console.error('Erreur parsing message SSH:', err.message);
}
});
ws.on('close', () => {
console.log('Connexion WebSocket SSH fermée');
if (sshClient) {
sshClient.end();
}
});
ws.on('error', (err) => {
console.error('Erreur WebSocket SSH:', err.message);
if (sshClient) {
sshClient.end();
}
});
});
console.log('Serveur WebSocket SSH initialisé sur /ws-ssh');
return wssSsh;
}
/**
* Gère la connexion SSH
*/
function handleConnect(ws, msg, onConnected) {
const { host, port, username, password, privateKey } = msg;
if (!host || !username) {
ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' }));
return;
}
const sshClient = new Client();
const connConfig = {
host: host,
port: port || 22,
username: username,
readyTimeout: 15000,
keepaliveInterval: 10000,
};
if (privateKey) {
connConfig.privateKey = privateKey;
} else if (password) {
connConfig.password = password;
} else {
ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' }));
return;
}
ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` }));
sshClient.on('ready', () => {
ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` }));
sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => {
if (err) {
ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` }));
sshClient.end();
return;
}
onConnected(sshClient, stream);
// Transmettre les données du terminal SSH vers le WebSocket
stream.on('data', (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
}
});
stream.stderr.on('data', (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
}
});
stream.on('close', () => {
ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' }));
sshClient.end();
});
});
});
sshClient.on('error', (err) => {
ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` }));
});
sshClient.on('end', () => {
ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' }));
});
sshClient.connect(connConfig);
}
module.exports = { initSSHWebSocket };

View File

@@ -23,6 +23,7 @@ const REPO_TO_APP_MAP = {
'veille-reglementaire': 'veille-reglementaire', 'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange', 'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard', 'manus-dashboard': 'manus-dashboard',
'itinova-budget-si': 'itinova-budget-si',
}; };
// Déploiements en cours (évite les doubles déclenchements) // Déploiements en cours (évite les doubles déclenchements)

View File

@@ -11,21 +11,26 @@ services:
- JWT_SECRET=manus-dashboard-jwt-secret-2026-recette - JWT_SECRET=manus-dashboard-jwt-secret-2026-recette
- ADMIN_USERNAME=adminItinova - ADMIN_USERNAME=adminItinova
- ADMIN_PASSWORD=Itinova69! - ADMIN_PASSWORD=Itinova69!
- GITEA_URL=https://git.santinova-soft.org - GITEA_URL=https://git.recette.santinova-soft.org
- GITEA_USERNAME=manus-admin - GITEA_USERNAME=manus-admin
- GITEA_PASSWORD=ManusGitea2026! - GITEA_PASSWORD=ManusGitea2026!
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
- GITEA_RECETTE_URL=http://gitea:3000
- GITEA_PASSWORD_PROD=ManusGitea2026!
- 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
extra_hosts:
- "git.santinova-soft.org:180.149.196.138"
networks: networks:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# Route HTTPS via dashboard.santinova-soft.org # Route HTTPS via dashboard.recette.santinova-soft.org
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
- "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"

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 Recette - 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,17 @@ 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 TerminalPage from './pages/TerminalPage';
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 +35,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 +43,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 +66,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 +91,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 +108,16 @@ export default function App() {
return <DockerPage />; return <DockerPage />;
case 'monitoring': case 'monitoring':
return <MonitoringPage />; return <MonitoringPage />;
case 'documentation':
return <DocumentationPage />;
case 'inventaire':
return <InventairePage />;
case 'terminal':
return <TerminalPage />;
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, TerminalSquare,
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,10 @@ 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 },
{ id: 'terminal', label: 'Terminal SSH', icon: TerminalSquare },
]; ];
export default function Sidebar({ export default function Sidebar({
currentPage, currentPage,
onNavigate, onNavigate,
@@ -36,24 +38,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">Recette</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 +69,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 +85,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

@@ -112,12 +112,12 @@ 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">Gitea</span> <span className="text-sm text-gray-400">Gitea</span>
<a <a
href="https://git.santinova-soft.org" href="https://git.recette.santinova-soft.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary-400 hover:text-primary-300" className="text-sm text-primary-400 hover:text-primary-300"
> >
git.santinova-soft.org git.recette.santinova-soft.org
</a> </a>
</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

@@ -77,12 +77,12 @@ export default function GiteaPage() {
<p className="text-gray-400 mt-1"> <p className="text-gray-400 mt-1">
Explorez les dépôts sur{' '} Explorez les dépôts sur{' '}
<a <a
href="https://git.santinova-soft.org" href="https://git.recette.santinova-soft.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary-400 hover:text-primary-300" className="text-primary-400 hover:text-primary-300"
> >
git.santinova-soft.org git.recette.santinova-soft.org
</a> </a>
</p> </p>
</div> </div>

View File

@@ -0,0 +1,458 @@
import React, { useState, useEffect } from 'react';
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } 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: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale',
repoName: 'pilotage-masse-salariale',
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'itinova-budget-si',
name: 'Gestion Budget Informatique',
repoName: 'itinova-budget-si',
urlRecette: 'https://budget-si.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'falc-generator',
name: 'FALC Generator',
repoName: 'falc-generator',
urlRecette: 'https://falc.recette.santinova-soft.org',
urlProd: 'https://falc.santinova-soft.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 Recette',
repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org',
},
];
function SyncBadge({ versionRecette, versionProd }) {
// Extraire le SHA (7 premiers caractères avant l'espace)
const shaRec = versionRecette ? versionRecette.split(' ')[0] : null;
const shaProd = versionProd ? versionProd.split(' ')[0] : null;
// Extraire la date entre parenthèses pour comparer
const dateRec = versionRecette ? versionRecette.match(/\((.+?)\)/)?.[1] : null;
const dateProd = versionProd ? versionProd.match(/\((.+?)\)/)?.[1] : null;
if (!shaRec && !shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
<Minus className="w-3 h-3" />
N/A
</span>
);
}
if (!shaRec) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title="Uniquement en production">
<ArrowDown className="w-3 h-3" />
Prod en avance
</span>
);
}
if (!shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
<ArrowUp className="w-3 h-3" />
Recette en avance
</span>
);
}
if (shaRec === shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-900/40 text-emerald-300 border border-emerald-700/40" title="Versions identiques">
<CheckCircle className="w-3 h-3" />
Synchronisé
</span>
);
}
// Comparer les dates pour savoir qui est en avance
// Format fr-FR : dd/mm/yyyy
const parseDate = (s) => {
if (!s) return null;
const parts = s.split('/');
if (parts.length === 3) return new Date(`${parts[2]}-${parts[1]}-${parts[0]}`);
return new Date(s);
};
const dRec = parseDate(dateRec);
const dProd = parseDate(dateProd);
if (dRec && dProd) {
if (dRec > dProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<ArrowUp className="w-3 h-3" />
Recette en avance
</span>
);
} else if (dProd > dRec) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<ArrowDown className="w-3 h-3" />
Prod en avance
</span>
);
}
}
// SHA différents mais dates non comparables
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<AlertTriangle className="w-3 h-3" />
Divergent
</span>
);
}
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>
{/* Stats */}
{!loading && inventory.length > 0 && (
<div className="grid grid-cols-5 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-emerald-700/30">
<div className="text-2xl font-bold text-emerald-400">
{inventory.filter((a) => {
const shaRec = a.repoRecette?.version?.split(' ')[0];
const shaProd = a.repoProd?.version?.split(' ')[0];
return shaRec && shaProd && shaRec === shaProd;
}).length}
</div>
<div className="text-xs text-gray-400 mt-1">Synchronisés</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>
)}
{/* 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-purple-400 uppercase tracking-wider">
<div className="flex items-center justify-center gap-2">
<ArrowUp className="w-4 h-4" />
Synchronisation
</div>
</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="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(5)].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>
{/* Synchronisation */}
<td className="px-3 py-3 text-center">
<SyncBadge
versionRecette={app.repoRecette?.version}
versionProd={app.repoProd?.version}
/>
</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>
</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 Recette</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>

View File

@@ -0,0 +1,449 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Terminal, Wifi, WifiOff, X, Plus, ChevronDown } from 'lucide-react';
// Serveurs SSH prédéfinis
const PRESET_SERVERS = [
{
id: 'recette',
label: 'Recette (78.138.58.109)',
host: '78.138.58.109',
port: 22,
username: 'root',
},
{
id: 'production',
label: 'Production (180.149.196.138)',
host: '180.149.196.138',
port: 22,
username: 'root',
},
];
export default function TerminalPage() {
const terminalRef = useRef(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
const wsRef = useRef(null);
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [xtermLoaded, setXtermLoaded] = useState(false);
// Formulaire de connexion
const [form, setForm] = useState({
preset: 'recette',
host: '78.138.58.109',
port: 22,
username: 'root',
password: '',
});
const [showForm, setShowForm] = useState(true);
const [error, setError] = useState('');
// Charger xterm.js dynamiquement depuis CDN
useEffect(() => {
const loadXterm = async () => {
if (window.Terminal) {
setXtermLoaded(true);
return;
}
// Charger le CSS xterm
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
document.head.appendChild(link);
// Charger xterm.js
await loadScript('https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js');
await loadScript('https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js');
setXtermLoaded(true);
};
loadXterm();
}, []);
const loadScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
};
// Initialiser xterm quand il est chargé et que le terminal est visible
useEffect(() => {
if (!xtermLoaded || !terminalRef.current || xtermRef.current) return;
const term = new window.Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", monospace',
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
scrollback: 1000,
allowProposedApi: true,
});
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = term;
fitAddonRef.current = fitAddon;
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Recette Santinova ║\x1b[0m');
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
term.writeln('');
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');
term.writeln('');
// Gérer la saisie utilisateur
term.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
});
// Gérer le redimensionnement
const resizeObserver = new ResizeObserver(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'resize',
rows: term.rows,
cols: term.cols,
}));
}
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [xtermLoaded]);
const handlePresetChange = (presetId) => {
const preset = PRESET_SERVERS.find(s => s.id === presetId);
if (preset) {
setForm(prev => ({
...prev,
preset: presetId,
host: preset.host,
port: preset.port,
username: preset.username,
}));
} else {
setForm(prev => ({ ...prev, preset: 'custom' }));
}
};
const handleConnect = useCallback(() => {
if (!form.host || !form.username || !form.password) {
setError('Hôte, utilisateur et mot de passe sont requis');
return;
}
setError('');
setConnecting(true);
setShowForm(false);
const token = localStorage.getItem('dashboard_token');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws-ssh?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'connect',
host: form.host,
port: parseInt(form.port),
username: form.username,
password: form.password,
}));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'status':
if (xtermRef.current) {
xtermRef.current.writeln(`\x1b[33m${msg.message}\x1b[0m`);
}
break;
case 'connected':
setConnected(true);
setConnecting(false);
if (xtermRef.current) {
xtermRef.current.writeln(`\x1b[32m✓ ${msg.message}\x1b[0m`);
xtermRef.current.writeln('');
xtermRef.current.focus();
}
break;
case 'output':
if (xtermRef.current) {
const data = atob(msg.data);
xtermRef.current.write(data);
}
break;
case 'disconnected':
setConnected(false);
setConnecting(false);
if (xtermRef.current) {
xtermRef.current.writeln('');
xtermRef.current.writeln(`\x1b[33m⚠ ${msg.message}\x1b[0m`);
}
break;
case 'error':
setConnected(false);
setConnecting(false);
setError(msg.message);
if (xtermRef.current) {
xtermRef.current.writeln(`\x1b[31m✗ Erreur: ${msg.message}\x1b[0m`);
}
setShowForm(true);
break;
}
} catch (err) {
console.error('Erreur parsing message SSH:', err);
}
};
ws.onclose = () => {
setConnected(false);
setConnecting(false);
};
ws.onerror = () => {
setConnected(false);
setConnecting(false);
setError('Erreur de connexion WebSocket');
setShowForm(true);
};
}, [form]);
const handleDisconnect = () => {
if (wsRef.current) {
wsRef.current.send(JSON.stringify({ type: 'disconnect' }));
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
setConnecting(false);
setShowForm(true);
if (xtermRef.current) {
xtermRef.current.writeln('');
xtermRef.current.writeln('\x1b[90mDéconnecté. Configurez une nouvelle connexion.\x1b[0m');
}
};
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">
<Terminal className="w-7 h-7 text-emerald-400" />
Terminal SSH
</h2>
<p className="text-gray-400 mt-1">
Connexion SSH sécurisée aux serveurs de l'infrastructure
</p>
</div>
<div className="flex items-center gap-2">
{connected ? (
<>
<span className="flex items-center gap-2 text-emerald-400 text-sm">
<Wifi className="w-4 h-4" />
Connecté à {form.host}
</span>
<button
onClick={handleDisconnect}
className="flex items-center gap-2 px-3 py-1.5 bg-red-600/20 text-red-400 border border-red-500/30 rounded-lg text-sm hover:bg-red-600/30 transition-colors"
>
<X className="w-4 h-4" />
Déconnecter
</button>
</>
) : connecting ? (
<span className="flex items-center gap-2 text-yellow-400 text-sm">
<div className="w-4 h-4 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
Connexion en cours...
</span>
) : (
<span className="flex items-center gap-2 text-gray-500 text-sm">
<WifiOff className="w-4 h-4" />
Non connecté
</span>
)}
</div>
</div>
{/* Formulaire de connexion */}
{showForm && (
<div className="bg-dark-800 border border-dark-600 rounded-xl p-6">
<h3 className="text-white font-semibold mb-4 flex items-center gap-2">
<Plus className="w-4 h-4 text-primary-400" />
Nouvelle connexion SSH
</h3>
{error && (
<div className="mb-4 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Serveur prédéfini */}
<div className="md:col-span-2">
<label className="block text-sm text-gray-400 mb-1">Serveur prédéfini</label>
<div className="relative">
<select
value={form.preset}
onChange={(e) => handlePresetChange(e.target.value)}
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm appearance-none focus:outline-none focus:border-primary-500"
>
{PRESET_SERVERS.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
<option value="custom">Serveur personnalisé...</option>
</select>
<ChevronDown className="absolute right-3 top-3 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
{/* Hôte */}
<div>
<label className="block text-sm text-gray-400 mb-1">Hôte / IP</label>
<input
type="text"
value={form.host}
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value, preset: 'custom' }))}
placeholder="192.168.1.1"
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
{/* Port */}
<div>
<label className="block text-sm text-gray-400 mb-1">Port SSH</label>
<input
type="number"
value={form.port}
onChange={(e) => setForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="22"
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
{/* Utilisateur */}
<div>
<label className="block text-sm text-gray-400 mb-1">Utilisateur</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="root"
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
{/* Mot de passe */}
<div>
<label className="block text-sm text-gray-400 mb-1">Mot de passe</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="••••••••"
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={handleConnect}
disabled={connecting}
className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Terminal className="w-4 h-4" />
Se connecter
</button>
</div>
</div>
)}
{/* Terminal xterm.js */}
<div className="bg-dark-900 border border-dark-600 rounded-xl overflow-hidden">
{/* Barre de titre style terminal */}
<div className="flex items-center gap-2 px-4 py-3 bg-dark-800 border-b border-dark-600">
<div className="w-3 h-3 rounded-full bg-red-500/70" />
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
<div className="w-3 h-3 rounded-full bg-green-500/70" />
<span className="ml-2 text-xs text-gray-500 font-mono">
{connected ? `${form.username}@${form.host}` : 'terminal non connecté'}
</span>
{connected && !showForm && (
<button
onClick={() => setShowForm(!showForm)}
className="ml-auto text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{showForm ? 'Masquer' : 'Nouvelle connexion'}
</button>
)}
</div>
{/* Zone du terminal */}
<div
ref={terminalRef}
style={{ height: '500px', padding: '8px', backgroundColor: '#0d1117' }}
/>
</div>
{!xtermLoaded && (
<div className="text-center text-gray-500 text-sm py-4">
Chargement du terminal...
</div>
)}
</div>
);
}