Compare commits
15 Commits
98df3de6c7
...
21c5457403
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c5457403 | ||
|
|
f833022f7a | ||
|
|
3a25eca200 | ||
|
|
7b464c3991 | ||
|
|
ae14e23c3b | ||
|
|
e6a3a0671c | ||
|
|
2b149a1f81 | ||
|
|
ed57d4af73 | ||
|
|
6bae1d569d | ||
|
|
b40300cfc6 | ||
|
|
ea09f631d1 | ||
|
|
c69bf3b522 | ||
|
|
04be6920a1 | ||
|
|
355e2a85e2 | ||
|
|
0781c76931 |
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
179
src/backend/src/ssh.js
Normal 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 };
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
src/frontend/public/infra_schema_v2.png
Normal file
BIN
src/frontend/public/infra_schema_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
420
src/frontend/src/pages/DocumentationPage.jsx
Normal file
420
src/frontend/src/pages/DocumentationPage.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
458
src/frontend/src/pages/InventairePage.jsx
Normal file
458
src/frontend/src/pages/InventairePage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
449
src/frontend/src/pages/TerminalPage.jsx
Normal file
449
src/frontend/src/pages/TerminalPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user