feat: page Inventaire, Documentation, corrections routes inventory (URLs publiques Gitea), port 3001, GITEA_PROD_INTERNAL_URL
This commit is contained in:
parent
800d82a929
commit
ee685f0326
|
|
@ -5,38 +5,35 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
container_name: manus-dashboard
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- JWT_SECRET=${DASHBOARD_JWT_SECRET}
|
||||
- ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD}
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_USERNAME=${GITEA_USERNAME}
|
||||
- GITEA_PASSWORD=${GITEA_PASSWORD}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||
- APPS_BASE_PATH=${APPS_BASE_PATH}
|
||||
- INFRA_BASE_PATH=${INFRA_BASE_PATH}
|
||||
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL}
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
JWT_SECRET: manus-dashboard-prod-secret-2026
|
||||
ADMIN_USERNAME: adminItinova
|
||||
ADMIN_PASSWORD: Itinova69!
|
||||
GITEA_URL: https://git.santinova-soft.org
|
||||
GITEA_USERNAME: manus-admin
|
||||
GITEA_PASSWORD: ManusGitea2026!
|
||||
APPS_BASE_PATH: /opt/manus-deploy/apps
|
||||
INFRA_BASE_PATH: /opt/manus-deploy/infrastructure
|
||||
WEBHOOK_SECRET: webhook-prod-secret-2026
|
||||
volumes:
|
||||
- /opt/manus-deploy/webhook-patched.js:/app/backend/src/webhook.js:ro
|
||||
- /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:
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)"
|
||||
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
||||
- "traefik.http.routers.manus-dashboard.tls=true"
|
||||
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
||||
- "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"
|
||||
- "traefik.http.routers.dashboard.rule=Host(`dashboard.santinova-soft.org`)"
|
||||
- "traefik.http.routers.dashboard.entrypoints=websecure"
|
||||
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.dashboard.loadbalancer.server.port=3001"
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -2,113 +2,105 @@ module.exports = {
|
|||
port: process.env.PORT || 3001,
|
||||
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
|
||||
jwtExpiry: '24h',
|
||||
// Authentification
|
||||
auth: {
|
||||
username: process.env.ADMIN_USERNAME || 'adminItinova',
|
||||
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
|
||||
},
|
||||
// Gitea
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || 'https://git.santinova-soft.org',
|
||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||
password: process.env.GITEA_PASSWORD || 'ManusGitea2026!',
|
||||
},
|
||||
// Applications config
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
|
||||
// Health check interval (ms)
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
|
||||
// Apps configuration - can be extended
|
||||
apps: [
|
||||
{
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
description: 'Application de gestion des contacts',
|
||||
directory: 'itinova-contacts',
|
||||
giteaRepo: 'itinova-contacts',
|
||||
giteaOwner: 'manus-admin',
|
||||
description: 'Application de gestion des contacts Itinova',
|
||||
urls: {
|
||||
recette: 'https://contacts.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
prod: 'https://contacts.santinova-soft.org',
|
||||
},
|
||||
giteaOwner: 'manus-admin',
|
||||
giteaRepo: 'itinova-contacts',
|
||||
composeFile: 'docker-compose.prod.yml',
|
||||
containerName: 'itinova-contacts',
|
||||
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
description: 'Application de gestion de podcasts',
|
||||
directory: 'itinova-podcasts',
|
||||
giteaRepo: 'itinova-podcasts',
|
||||
giteaOwner: 'manus-admin',
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Gestion de Flotte',
|
||||
description: 'Bourse de vehicules Itinova - Partage et echange de vehicules entre etablissements',
|
||||
urls: {
|
||||
recette: 'https://podcasts.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
recette: 'https://flotte.recette.santinova-soft.org',
|
||||
prod: 'https://flotte.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-podcasts',
|
||||
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
giteaOwner: 'manus-admin',
|
||||
giteaRepo: 'itinova-vehicle-exchange',
|
||||
composeFile: 'docker-compose.yml',
|
||||
containerName: 'itinova-vehicle-exchange',
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'veille-reglementaire',
|
||||
name: 'Veille Réglementaire',
|
||||
description: 'Application de veille réglementaire et appels à projets',
|
||||
directory: 'veille-reglementaire',
|
||||
giteaRepo: 'veille-reglementaire',
|
||||
giteaOwner: 'manus-admin',
|
||||
description: 'Application de veille réglementaire — Direction des Opérations Itinova',
|
||||
urls: {
|
||||
recette: 'https://veille.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
prod: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
containerName: 'veille-reglementaire',
|
||||
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Itinova Gestion de Flotte',
|
||||
description: 'Application de gestion de flotte de véhicules',
|
||||
directory: 'itinova-vehicle-exchange',
|
||||
giteaRepo: 'itinova-vehicle-exchange',
|
||||
giteaOwner: 'manus-admin',
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
description: 'Gestionnaire de podcasts pour les établissements Itinova',
|
||||
urls: {
|
||||
recette: 'https://flotte.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
recette: 'https://podcasts.recette.santinova-soft.org',
|
||||
prod: 'https://podcasts.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-vehicle-exchange',
|
||||
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
giteaOwner: 'manus-admin',
|
||||
giteaRepo: 'itinova-podcasts',
|
||||
composeFile: 'docker-compose.yml',
|
||||
containerName: 'itinova-podcasts',
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'sonum',
|
||||
name: 'Sonum',
|
||||
description: 'Application Sonum',
|
||||
directory: 'sonum',
|
||||
giteaRepo: 'sonum',
|
||||
giteaOwner: 'manus-admin',
|
||||
name: 'SONUM',
|
||||
description: 'Cartographie des Solutions Numériques FEHAP',
|
||||
urls: {
|
||||
recette: 'https://sonum.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
prod: 'https://sonum.santinova-soft.org',
|
||||
},
|
||||
giteaOwner: 'manus-admin',
|
||||
giteaRepo: 'sonum',
|
||||
composeFile: 'docker-compose.yml',
|
||||
containerName: 'sonum',
|
||||
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'facturation-santinova',
|
||||
name: 'Facturation Santinova',
|
||||
description: 'Application de gestion de la facturation',
|
||||
directory: 'facturation-santinova',
|
||||
giteaRepo: 'facturation-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
id: 'demat-facturation-dsi',
|
||||
name: 'Démat Facturation DSI',
|
||||
description: 'Dématérialisation de la facturation fournisseurs — Direction des Systèmes d\'Information',
|
||||
urls: {
|
||||
recette: 'https://facturation.recette.santinova-soft.org',
|
||||
prod: null,
|
||||
recette: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
prod: 'https://demat-facturation.santinova-soft.org',
|
||||
},
|
||||
containerName: 'facturation-santinova-app',
|
||||
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
|
||||
port: 3001,
|
||||
giteaOwner: 'manus-admin',
|
||||
giteaRepo: 'demat-facturation-dsi',
|
||||
composeFile: 'docker-compose.yml',
|
||||
containerName: 'demat-facturation-app',
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
const Docker = require('dockerode');
|
||||
const { exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
// Chemin du fichier de métriques écrit par le script host
|
||||
const HOST_METRICS_FILE = '/opt/manus-deploy/logs/host_metrics.json';
|
||||
|
||||
/**
|
||||
* Récupérer les informations d'un conteneur Docker
|
||||
*/
|
||||
|
|
@ -38,7 +42,6 @@ async function getContainerLogs(containerName, tail = 100) {
|
|||
tail: tail,
|
||||
timestamps: true,
|
||||
});
|
||||
// Parse buffer to string
|
||||
return logs.toString('utf8');
|
||||
} catch (err) {
|
||||
return `Erreur lors de la récupération des logs: ${err.message}`;
|
||||
|
|
@ -80,8 +83,8 @@ function redeployApp(appConfig) {
|
|||
logLines.push(`[${new Date().toISOString()}] Commande: ${cmd}`);
|
||||
|
||||
const process = exec(cmd, {
|
||||
maxBuffer: 1024 * 1024 * 10, // 10MB
|
||||
timeout: 300000, // 5 minutes
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
timeout: 300000,
|
||||
});
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
|
|
@ -134,8 +137,6 @@ function gitPull(appConfig) {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Démarrer un conteneur Docker
|
||||
*/
|
||||
|
|
@ -176,84 +177,135 @@ async function restartContainer(containerName) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Récupérer les métriques système du serveur (CPU, RAM, disque, réseau)
|
||||
* Récupérer les métriques système du serveur (CPU, RAM, disque, réseau, uptime)
|
||||
*
|
||||
* Architecture LXC + Docker : le conteneur est isolé dans ses namespaces réseau/PID,
|
||||
* ce qui empêche la lecture directe de /proc pour certaines métriques.
|
||||
*
|
||||
* Solution : lire le fichier /opt/manus-deploy/logs/host_metrics.json
|
||||
* écrit en continu par le script metrics_collector.sh tournant sur le VPS hôte.
|
||||
* Ce fichier est accessible via le volume monté dans le conteneur.
|
||||
*
|
||||
* Fallback : calcul direct depuis /proc (moins précis pour RAM/uptime/réseau).
|
||||
*/
|
||||
function getServerMetrics() {
|
||||
return new Promise((resolve) => {
|
||||
const { execSync } = require("child_process");
|
||||
// ── Tentative 1 : lire le fichier de métriques du host ─────────────────
|
||||
try {
|
||||
// nsenter -t 1 -m lit les métriques réelles de l'hôte VPS (pas de l'hyperviseur)
|
||||
const ns = "nsenter -t 1 -m --";
|
||||
if (fs.existsSync(HOST_METRICS_FILE)) {
|
||||
const stat = fs.statSync(HOST_METRICS_FILE);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
// Fichier valide si moins de 60 secondes
|
||||
if (ageMs < 60000) {
|
||||
const raw = fs.readFileSync(HOST_METRICS_FILE, 'utf8');
|
||||
const metrics = JSON.parse(raw);
|
||||
// Rafraîchir le timestamp
|
||||
metrics.timestamp = new Date().toISOString();
|
||||
return resolve(metrics);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fichier absent ou corrompu → fallback
|
||||
}
|
||||
|
||||
// CPU : delta /proc/stat sur 200ms
|
||||
// ── Fallback : calcul depuis /proc (valeurs approchées) ────────────────
|
||||
const { execSync } = require('child_process');
|
||||
try {
|
||||
// CPU via /host/proc/stat (namespace CPU partagé avec le VPS)
|
||||
let cpuUsage = 0;
|
||||
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;
|
||||
const procStat = fs.existsSync('/host/proc/stat') ? '/host/proc/stat' : '/proc/stat';
|
||||
const s1 = execSync(`grep '^cpu ' ${procStat}`).toString().trim().split(/\s+/).slice(1).map(Number);
|
||||
execSync('sleep 0.3');
|
||||
const s2 = execSync(`grep '^cpu ' ${procStat}`).toString().trim().split(/\s+/).slice(1).map(Number);
|
||||
const total1 = s1.reduce((a, b) => a + b, 0);
|
||||
const total2 = s2.reduce((a, b) => a + b, 0);
|
||||
const dTotal = total2 - total1;
|
||||
const dIdle = s2[3] - s1[3];
|
||||
cpuUsage = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
|
||||
} catch(e) {
|
||||
} catch (e) {}
|
||||
|
||||
// RAM depuis cgroup v2 du VPS (accessible via /sys/fs/cgroup monté ou /host/cgroup)
|
||||
let memTotal = 0, memUsed = 0, memFree = 0, memAvailable = 0;
|
||||
try {
|
||||
const cpuRaw = execSync(`${ns} top -bn1 | grep 'Cpu(s)' | awk '{print $2}'`).toString().trim();
|
||||
cpuUsage = parseFloat(cpuRaw) || 0;
|
||||
// Essayer /host/cgroup d'abord, puis /sys/fs/cgroup
|
||||
const cgroupBase = fs.existsSync('/host/cgroup/memory.max') ? '/host/cgroup' : '/sys/fs/cgroup';
|
||||
const memMaxRaw = fs.readFileSync(`${cgroupBase}/memory.max`, 'utf8').trim();
|
||||
const memCurrRaw = fs.readFileSync(`${cgroupBase}/memory.current`, 'utf8').trim();
|
||||
|
||||
if (memMaxRaw !== 'max') {
|
||||
memTotal = parseInt(memMaxRaw);
|
||||
memUsed = parseInt(memCurrRaw);
|
||||
// Tenter de lire la RAM inactive (cache) depuis memory.stat
|
||||
try {
|
||||
const memStat = fs.readFileSync(`${cgroupBase}/memory.stat`, 'utf8');
|
||||
const inactiveFile = parseInt((memStat.match(/^inactive_file\s+(\d+)/m) || [0, 0])[1]);
|
||||
const activeFile = parseInt((memStat.match(/^active_file\s+(\d+)/m) || [0, 0])[1]);
|
||||
const cache = inactiveFile + activeFile;
|
||||
memAvailable = memTotal - memUsed + cache;
|
||||
memFree = memAvailable;
|
||||
} catch (e) {
|
||||
memAvailable = memTotal - memUsed;
|
||||
memFree = memAvailable;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback /proc/meminfo (valeurs du namespace du conteneur)
|
||||
try {
|
||||
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
|
||||
const getVal = (key) => {
|
||||
const m = meminfo.match(new RegExp(`^${key}:\\s+(\\d+)`, 'm'));
|
||||
return m ? parseInt(m[1]) * 1024 : 0;
|
||||
};
|
||||
memTotal = getVal('MemTotal');
|
||||
memFree = getVal('MemFree');
|
||||
memAvailable = getVal('MemAvailable') || memFree;
|
||||
memUsed = memTotal - memAvailable;
|
||||
} catch (e2) {}
|
||||
}
|
||||
|
||||
// RAM : cgroup de l'hôte (fiable même en environnement VPS virtualisé)
|
||||
// La RAM totale est lue depuis /sys/fs/cgroup/memory.max de l'hôte
|
||||
// La RAM utilisée est calculée via docker stats (valeurs réelles du VPS)
|
||||
let memTotal = 8589934592; // 8 Go par défaut
|
||||
// Disque : df sur /
|
||||
let diskTotal = 0, diskUsed = 0, diskFree = 0;
|
||||
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();
|
||||
if (cgroupMax && cgroupMax !== 'max' && !isNaN(parseInt(cgroupMax))) {
|
||||
memTotal = parseInt(cgroupMax);
|
||||
}
|
||||
const diskRaw = execSync('df -B1 / | tail -1').toString().trim().split(/\s+/);
|
||||
diskTotal = parseInt(diskRaw[1]);
|
||||
diskUsed = parseInt(diskRaw[2]);
|
||||
diskFree = parseInt(diskRaw[3]);
|
||||
} catch (e) {}
|
||||
|
||||
// RAM utilisée : somme de tous les conteneurs via docker stats
|
||||
let memUsed = 0;
|
||||
// Uptime depuis /proc/uptime du VPS (namespace partagé avec le VPS)
|
||||
let uptimeSeconds = 0;
|
||||
try {
|
||||
const statsRaw = execSync("docker stats --no-stream --format '{{.MemUsage}}'").toString().trim();
|
||||
statsRaw.split('\n').forEach(line => {
|
||||
const match = line.match(/([\d.]+)(\w+)\s*\//);
|
||||
if (match) {
|
||||
const val = parseFloat(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
if (unit === 'gib') memUsed += val * 1024 * 1024 * 1024;
|
||||
else if (unit === 'mib') memUsed += val * 1024 * 1024;
|
||||
else if (unit === 'kib') memUsed += val * 1024;
|
||||
else memUsed += val;
|
||||
}
|
||||
});
|
||||
const uptimeFile = fs.existsSync('/host/proc/uptime') ? '/host/proc/uptime' : '/proc/uptime';
|
||||
uptimeSeconds = parseFloat(fs.readFileSync(uptimeFile, 'utf8').trim().split(' ')[0]) || 0;
|
||||
} catch (e) {}
|
||||
const memAvailable = memTotal - memUsed;
|
||||
const memFree = memAvailable;
|
||||
|
||||
// Disque : df sur l'hôte
|
||||
const diskRaw = execSync(`${ns} df -B1 / | tail -1`).toString().trim().split(/\s+/);
|
||||
const diskTotal = parseInt(diskRaw[1]);
|
||||
const diskUsed = parseInt(diskRaw[2]);
|
||||
const diskFree = parseInt(diskRaw[3]);
|
||||
// Load average
|
||||
let load1 = 0, load5 = 0, load15 = 0;
|
||||
try {
|
||||
const loadFile = fs.existsSync('/host/proc/loadavg') ? '/host/proc/loadavg' : '/proc/loadavg';
|
||||
const loadRaw = fs.readFileSync(loadFile, 'utf8').trim().split(' ');
|
||||
load1 = parseFloat(loadRaw[0]) || 0;
|
||||
load5 = parseFloat(loadRaw[1]) || 0;
|
||||
load15 = parseFloat(loadRaw[2]) || 0;
|
||||
} catch (e) {}
|
||||
|
||||
// Uptime et load
|
||||
const uptimeRaw = execSync(`${ns} cat /proc/uptime`).toString().trim().split(" ");
|
||||
const uptimeSeconds = parseFloat(uptimeRaw[0]);
|
||||
const loadRaw = execSync(`${ns} cat /proc/loadavg`).toString().trim().split(" ");
|
||||
const load1 = parseFloat(loadRaw[0]);
|
||||
const load5 = parseFloat(loadRaw[1]);
|
||||
const load15 = parseFloat(loadRaw[2]);
|
||||
|
||||
// Réseau
|
||||
// Réseau : /proc/net/dev du VPS (namespace réseau partagé avec le VPS, pas le conteneur)
|
||||
let netRx = 0, netTx = 0;
|
||||
try {
|
||||
const netRaw = execSync(`${ns} cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1`).toString().trim().split(/\s+/);
|
||||
netRx = parseInt(netRaw[1]) || 0;
|
||||
netTx = parseInt(netRaw[9]) || 0;
|
||||
const netFile = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev';
|
||||
const netRaw = fs.readFileSync(netFile, 'utf8');
|
||||
const line = netRaw.split('\n').find((l) => /^\s*(eth0|venet0|ens\d+|enp\d+)/.test(l));
|
||||
if (line) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
netRx = parseInt(parts[1]) || 0;
|
||||
netTx = parseInt(parts[9]) || 0;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const memPct = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0;
|
||||
const diskPct = diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0;
|
||||
|
||||
resolve({
|
||||
cpu: { usage: cpuUsage },
|
||||
memory: {
|
||||
|
|
@ -261,24 +313,26 @@ function getServerMetrics() {
|
|||
used: memUsed,
|
||||
free: memFree,
|
||||
available: memAvailable,
|
||||
usagePercent: Math.round((memUsed / memTotal) * 100)
|
||||
usagePercent: memPct,
|
||||
},
|
||||
disk: {
|
||||
total: diskTotal,
|
||||
used: diskUsed,
|
||||
free: diskFree,
|
||||
usagePercent: Math.round((diskUsed / diskTotal) * 100)
|
||||
usagePercent: diskPct,
|
||||
},
|
||||
uptime: uptimeSeconds,
|
||||
load: { load1, load5, load15 },
|
||||
network: { rx: netRx, tx: netTx },
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'fallback',
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
docker,
|
||||
getContainerInfo,
|
||||
|
|
|
|||
|
|
@ -475,3 +475,78 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
|
|||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||
const { exec } = require('child_process');
|
||||
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 || 'Itinova69!';
|
||||
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' },
|
||||
];
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl) {
|
||||
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) {}
|
||||
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),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD, GITEA_PROD_PUBLIC),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
);
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error('Erreur /inventory:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
container_name: manus-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
|
|
@ -13,7 +15,10 @@ services:
|
|||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_PASSWORD=Itinova69!
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- GITEA_RECETTE_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_PROD_INTERNAL_URL=http://172.18.0.9:3000
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
|
|
@ -6,16 +6,16 @@ import MonitoringPage from './pages/MonitoringPage';
|
|||
import DeploymentsPage from './pages/DeploymentsPage';
|
||||
import GiteaPage from './pages/GiteaPage';
|
||||
import DockerPage from './pages/DockerPage';
|
||||
import DocumentationPage from './pages/DocumentationPage';
|
||||
import InventairePage from './pages/InventairePage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [apps, setApps] = useState([]);
|
||||
|
||||
// Vérifier l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
|
|
@ -34,7 +34,6 @@ export default function App() {
|
|||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchApps = async () => {
|
||||
try {
|
||||
const res = await getApps();
|
||||
|
|
@ -43,23 +42,19 @@ export default function App() {
|
|||
console.error('Erreur chargement apps:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket handler
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.type === 'health_update') {
|
||||
setApps(data.data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { connected: wsConnected } = useWebSocket(
|
||||
authenticated ? handleWsMessage : null
|
||||
);
|
||||
|
||||
const handleLogin = (data) => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
|
|
@ -70,7 +65,6 @@ export default function App() {
|
|||
setAuthenticated(false);
|
||||
setApps([]);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950">
|
||||
|
|
@ -96,11 +90,9 @@ export default function App() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
|
|
@ -115,11 +107,14 @@ export default function App() {
|
|||
return <DockerPage />;
|
||||
case 'monitoring':
|
||||
return <MonitoringPage />;
|
||||
case 'documentation':
|
||||
return <DocumentationPage />;
|
||||
case 'inventaire':
|
||||
return <InventairePage />;
|
||||
default:
|
||||
return <DashboardPage apps={apps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950">
|
||||
<Sidebar
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
import { LayoutList,
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
|
|
@ -10,8 +10,8 @@ import {
|
|||
Wifi,
|
||||
WifiOff,
|
||||
Activity,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'apps', label: 'Applications', icon: Box },
|
||||
|
|
@ -19,8 +19,9 @@ const navItems = [
|
|||
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
|
||||
{ id: 'docker', label: 'Docker', icon: Container },
|
||||
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
|
||||
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
|
||||
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
|
|
@ -41,19 +42,23 @@ export default function Sidebar({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
const isDoc = item.id === 'documentation';
|
||||
return (
|
||||
<button
|
||||
key={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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -63,7 +68,6 @@ export default function Sidebar({
|
|||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-700 space-y-3">
|
||||
{/* WebSocket status */}
|
||||
|
|
@ -80,7 +84,6 @@ export default function Sidebar({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export default function DashboardPage({ apps }) {
|
|||
<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-200">
|
||||
78.138.58.109 (Recette)
|
||||
180.149.196.138 (Production)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
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 Manus',
|
||||
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) => (
|
||||
<tr key={app.id} className="hover:bg-dark-800/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">{app.name}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export default function LoginPage({ onLogin }) {
|
|||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-sm mt-6">
|
||||
Santinova Soft — Serveur de recette
|
||||
Santinova Soft — Serveur de production
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue