diff --git a/src/backend/package.json b/src/backend/package.json index d210140..3dce267 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -21,7 +21,8 @@ "cookie-parser": "^1.4.6", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", - "adm-zip": "^0.5.10" + "adm-zip": "^0.5.10", + "ssh2": "^1.16.0" }, "devDependencies": { "nodemon": "^3.1.0" diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 664c1a7..34fe2e5 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -12,6 +12,7 @@ module.exports = { url: process.env.GITEA_URL || 'http://gitea:3000', username: process.env.GITEA_USERNAME || 'manus-admin', password: process.env.GITEA_PASSWORD || 'Itinova69!', + token: process.env.GITEA_TOKEN || null, }, // Applications config appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', @@ -65,7 +66,7 @@ module.exports = { recette: 'https://veille.recette.santinova-soft.org', prod: 'https://veille.santinova-soft.org', }, - containerName: 'veille-reglementaire', + containerName: 'veille-reglementaire-recette', healthCheckUrl: 'https://veille.recette.santinova-soft.org', port: 3000, category: 'ITINOVA', @@ -88,6 +89,23 @@ module.exports = { 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', name: 'SONUM', @@ -190,5 +208,22 @@ module.exports = { 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', + }, ], }; diff --git a/src/backend/src/gitea.js b/src/backend/src/gitea.js index c899c89..84f1d1b 100644 --- a/src/backend/src/gitea.js +++ b/src/backend/src/gitea.js @@ -3,12 +3,17 @@ const https = require('https'); const config = require('./config'); // 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({ baseURL: `${config.gitea.url}/api/v1`, - auth: { - username: config.gitea.username, - password: config.gitea.password, - }, + ...(config.gitea.token + ? { headers: giteaClientHeaders } + : { auth: { username: config.gitea.username, password: config.gitea.password } } + ), httpsAgent: new https.Agent({ rejectUnauthorized: false }), timeout: 10000, }); diff --git a/src/backend/src/index.js b/src/backend/src/index.js index beae15a..07b03f6 100644 --- a/src/backend/src/index.js +++ b/src/backend/src/index.js @@ -15,12 +15,14 @@ const webhookRoutes = require('./webhook'); const { checkAllApps, getAllStatuses } = require('./healthcheck'); const { getCommits } = require('./gitea'); const { initDynamicApps } = require('./app-creator'); +const { initSSHWebSocket } = require('./ssh'); const app = express(); const server = http.createServer(app); // WebSocket server pour les mises à jour en temps réel const wss = new WebSocket.Server({ server, path: '/ws' }); +initSSHWebSocket(server); // Middleware app.use(helmet({ diff --git a/src/backend/src/routes.js b/src/backend/src/routes.js index 106638e..45fbc9a 100644 --- a/src/backend/src/routes.js +++ b/src/backend/src/routes.js @@ -503,6 +503,7 @@ 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' }, @@ -513,21 +514,26 @@ const INVENTORY_APPS = [ { 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) { +function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) { return new Promise((resolve) => { - const auth = `${GITEA_USER_INV}:${pass}`; - const cmd = `curl -sk --max-time 6 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}"`; + 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 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`; + 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 { @@ -556,7 +562,7 @@ router.get('/inventory', authMiddleware, async (req, res) => { 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), + 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 }; diff --git a/src/backend/src/ssh.js b/src/backend/src/ssh.js new file mode 100644 index 0000000..34a3faf --- /dev/null +++ b/src/backend/src/ssh.js @@ -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 }; diff --git a/src/backend/src/webhook.js b/src/backend/src/webhook.js index 6baf388..69ff5fd 100644 --- a/src/backend/src/webhook.js +++ b/src/backend/src/webhook.js @@ -23,6 +23,7 @@ const REPO_TO_APP_MAP = { 'veille-reglementaire': 'veille-reglementaire', 'itinova-vehicle-exchange': 'itinova-vehicle-exchange', 'manus-dashboard': 'manus-dashboard', + 'itinova-budget-si': 'itinova-budget-si', }; // Déploiements en cours (évite les doubles déclenchements) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 68c589a..9b5bd8a 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -13,8 +13,9 @@ services: - ADMIN_PASSWORD=Itinova69! - GITEA_URL=https://git.recette.santinova-soft.org - GITEA_USERNAME=manus-admin - - GITEA_PASSWORD=Itinova69! - - GITEA_RECETTE_URL=http://172.18.0.5:3000 + - GITEA_PASSWORD=ManusGitea2026! + - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427 + - GITEA_RECETTE_URL=http://gitea:3000 - GITEA_PASSWORD_PROD=ManusGitea2026! - APPS_BASE_PATH=/opt/manus-deploy/apps - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index 6642b20..dcbd001 100644 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -8,6 +8,7 @@ import GiteaPage from './pages/GiteaPage'; 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 useWebSocket from './hooks/useWebSocket'; import { getMe, getApps, logout as apiLogout } from './utils/api'; @@ -111,6 +112,8 @@ export default function App() { return ; case 'inventaire': return ; + case 'terminal': + return ; default: return ; } diff --git a/src/frontend/src/components/Sidebar.jsx b/src/frontend/src/components/Sidebar.jsx index f9511a1..7e5255e 100644 --- a/src/frontend/src/components/Sidebar.jsx +++ b/src/frontend/src/components/Sidebar.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LayoutList, +import { LayoutList, TerminalSquare, Server, LayoutDashboard, Box, @@ -21,6 +21,7 @@ const navItems = [ { 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({ currentPage, diff --git a/src/frontend/src/pages/GiteaPage.jsx b/src/frontend/src/pages/GiteaPage.jsx index 892bbb7..e36f657 100644 --- a/src/frontend/src/pages/GiteaPage.jsx +++ b/src/frontend/src/pages/GiteaPage.jsx @@ -77,12 +77,12 @@ export default function GiteaPage() {

Explorez les dépôts sur{' '} - git.santinova-soft.org + git.recette.santinova-soft.org

diff --git a/src/frontend/src/pages/InventairePage.jsx b/src/frontend/src/pages/InventairePage.jsx index 79d9d73..10be926 100644 --- a/src/frontend/src/pages/InventairePage.jsx +++ b/src/frontend/src/pages/InventairePage.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle } from 'lucide-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 = [ @@ -59,6 +59,27 @@ const APPS_CONFIG = [ 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', @@ -75,6 +96,84 @@ const APPS_CONFIG = [ }, ]; + +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 ( + + + N/A + + ); + } + if (!shaRec) { + return ( + + + Prod en avance + + ); + } + if (!shaProd) { + return ( + + + Recette en avance + + ); + } + if (shaRec === shaProd) { + return ( + + + Synchronisé + + ); + } + // 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 ( + + + Recette en avance + + ); + } else if (dProd > dRec) { + return ( + + + Prod en avance + + ); + } + } + // SHA différents mais dates non comparables + return ( + + + Divergent + + ); +} + function VersionBadge({ version, loading }) { if (loading) { return ( @@ -190,6 +289,43 @@ export default function InventairePage() { + {/* Stats */} + {!loading && inventory.length > 0 && ( +
+
+
{inventory.length}
+
Applications totales
+
+
+
+ {inventory.filter((a) => a.repoRecette?.present).length} +
+
Dépôts Recette
+
+
+
+ {inventory.filter((a) => a.repoProd?.present).length} +
+
Dépôts Production
+
+
+
+ {inventory.filter((a) => { + const shaRec = a.repoRecette?.version?.split(' ')[0]; + const shaProd = a.repoProd?.version?.split(' ')[0]; + return shaRec && shaProd && shaRec === shaProd; + }).length} +
+
Synchronisés
+
+
+
+ {inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length} +
+
Déployés sur les 2 envs
+
+
+ )} {/* Légende */}
@@ -215,6 +351,12 @@ export default function InventairePage() { Application + +
+ + Synchronisation +
+
@@ -229,6 +371,7 @@ export default function InventairePage() { + Dépôt Git Version @@ -243,7 +386,7 @@ export default function InventairePage() { {app.name} - {[...Array(4)].map((_, i) => ( + {[...Array(5)].map((_, i) => (
@@ -265,6 +408,13 @@ export default function InventairePage() { {app.repoName}
+ {/* Synchronisation */} + + + {/* Recette - Dépôt */}
- {/* Stats */} - {!loading && inventory.length > 0 && ( -
-
-
{inventory.length}
-
Applications totales
-
-
-
- {inventory.filter((a) => a.repoRecette?.present).length} -
-
Dépôts Recette
-
-
-
- {inventory.filter((a) => a.repoProd?.present).length} -
-
Dépôts Production
-
-
-
- {inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length} -
-
Déployés sur les 2 envs
-
-
- )}
); } diff --git a/src/frontend/src/pages/TerminalPage.jsx b/src/frontend/src/pages/TerminalPage.jsx new file mode 100644 index 0000000..d1cf1d4 --- /dev/null +++ b/src/frontend/src/pages/TerminalPage.jsx @@ -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 ( +
+ {/* Header */} +
+
+

+ + Terminal SSH +

+

+ Connexion SSH sécurisée aux serveurs de l'infrastructure +

+
+
+ {connected ? ( + <> + + + Connecté à {form.host} + + + + ) : connecting ? ( + +
+ Connexion en cours... + + ) : ( + + + Non connecté + + )} +
+
+ + {/* Formulaire de connexion */} + {showForm && ( +
+

+ + Nouvelle connexion SSH +

+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Serveur prédéfini */} +
+ +
+ + +
+
+ + {/* Hôte */} +
+ + 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" + /> +
+ + {/* Port */} +
+ + 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" + /> +
+ + {/* Utilisateur */} +
+ + 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" + /> +
+ + {/* Mot de passe */} +
+ + 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" + /> +
+
+ +
+ +
+
+ )} + + {/* Terminal xterm.js */} +
+ {/* Barre de titre style terminal */} +
+
+
+
+ + {connected ? `${form.username}@${form.host}` : 'terminal — non connecté'} + + {connected && !showForm && ( + + )} +
+ + {/* Zone du terminal */} +
+
+ + {!xtermLoaded && ( +
+ Chargement du terminal... +
+ )} +
+ ); +}