diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b12d20 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +README.md +*.log +frontend/node_modules +backend/node_modules diff --git a/.gitignore b/.gitignore index aa0926a..4274b51 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .env *.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82c8d4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend package files +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ +RUN npm install + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend +RUN npm run build + +# Stage 2: Production +FROM node:20-alpine + +WORKDIR /app + +# Install docker CLI for docker compose commands +RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh-client + +# Configure git global identity to avoid "Author identity unknown" errors +RUN git config --global user.email "admin@santinova-soft.org" && \ + git config --global user.name "Manus Dashboard" + +# Copy backend +COPY backend/package.json ./backend/ +WORKDIR /app/backend +RUN npm install --production + +COPY backend/ ./ + +# Copy built frontend +WORKDIR /app +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +WORKDIR /app/backend + +EXPOSE 3001 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cf68ec --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Manus Dashboard + +Tableau de bord de gestion des applications Manus, déployé sur le serveur de recette. + +## Fonctionnalités + +- Liste des applications déployées avec état en temps réel +- Health checks HTTP périodiques avec indicateurs visuels +- Redéploiement en un clic (docker compose build + up) +- Logs de déploiement et logs conteneurs +- Intégration Gitea (commits, branches, dépôts) +- Authentification par login/mot de passe +- Interface moderne avec TailwindCSS + +## Stack technique + +- **Backend** : Node.js / Express +- **Frontend** : React / TailwindCSS / Vite +- **Conteneurisation** : Docker / Docker Compose +- **Reverse Proxy** : Traefik avec SSL Let's Encrypt + +## Déploiement + +```bash +cd /opt/manus-deploy/apps/manus-dashboard +docker compose build --no-cache && docker compose up -d +``` + +## Accès + +- URL : https://dashboard.santinova-soft.org +- Login : adminItinova / Itinova69! diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3dce267 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "manus-dashboard-backend", + "version": "1.0.0", + "description": "Backend API for Manus Dashboard", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "axios": "^1.7.0", + "dockerode": "^4.0.2", + "ws": "^8.18.0", + "node-cron": "^3.0.3", + "express-rate-limit": "^7.4.0", + "cookie-parser": "^1.4.6", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "adm-zip": "^0.5.10", + "ssh2": "^1.16.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/backend/src/app-creator.js b/backend/src/app-creator.js new file mode 100644 index 0000000..535aef0 --- /dev/null +++ b/backend/src/app-creator.js @@ -0,0 +1,710 @@ +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const { exec, execSync } = require('child_process'); +const config = require('./config'); +const { createRepo, giteaClient } = require('./gitea'); + +// ============ TEMPLATES DOCKERFILE ============ + +const DOCKERFILE_TEMPLATES = { + nodejs: (port) => `# ── Stage 1: Build ────────────────────────────────────────────── +FROM node:22-slim AS builder + +WORKDIR /app + +# Copy lockfiles for better layer caching +COPY package.json pnpm-lock.yaml* yarn.lock* ./ + +# Copy patches directory if it exists (needed by pnpm before install) +COPY patches/ ./patches/ + +# Detect package manager and install ALL dependencies (including devDependencies for build) +RUN if [ -f pnpm-lock.yaml ]; then \\ + corepack enable && corepack prepare pnpm@latest --activate && \\ + pnpm install --no-frozen-lockfile; \\ + elif [ -f yarn.lock ]; then \\ + yarn install; \\ + else \\ + npm install --legacy-peer-deps; \\ + fi + +# Copy source code +COPY . . + +# Build the application (frontend + backend if applicable) +RUN if [ -f package.json ] && grep -q '"build"' package.json; then \\ + if [ -f pnpm-lock.yaml ]; then \\ + pnpm run build; \\ + elif [ -f yarn.lock ]; then \\ + yarn build; \\ + else \\ + npm run build; \\ + fi; \\ + fi + +# ── Stage 2: Production ───────────────────────────────────────── +FROM node:22-slim + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml* yarn.lock* ./ + +# Copy patches directory if it exists (needed by pnpm for install) +COPY patches/ ./patches/ + +# Install ALL dependencies (some projects import dev deps at runtime like vite) +RUN if [ -f pnpm-lock.yaml ]; then \\ + corepack enable && corepack prepare pnpm@latest --activate && \\ + pnpm install --no-frozen-lockfile; \\ + elif [ -f yarn.lock ]; then \\ + yarn install; \\ + else \\ + npm install --legacy-peer-deps; \\ + fi + +# Copy built artifacts from builder +COPY --from=builder /app/dist ./dist + +# Copy drizzle migrations if they exist +COPY drizzle/ ./drizzle/ +COPY drizzle.config.ts* ./ + +ENV NODE_ENV=production +ENV PORT=\${port} +EXPOSE \${port} + +# Start the application +CMD if grep -q '"start"' package.json; then \\ + npm start; \\ + elif [ -f dist/index.js ]; then \\ + node dist/index.js; \\ + else \\ + node index.js; \\ + fi +`, + + 'nodejs-next': (port) => `# ── Stage 1: Build ────────────────────────────────────────────── +FROM node:22-slim AS builder + +WORKDIR /app +COPY package.json pnpm-lock.yaml* yarn.lock* ./ +RUN if [ -f pnpm-lock.yaml ]; then \\ + corepack enable && corepack prepare pnpm@latest --activate && \\ + pnpm install --no-frozen-lockfile; \\ + elif [ -f yarn.lock ]; then \\ + yarn install; \\ + else \\ + npm install --legacy-peer-deps; \\ + fi +COPY . . +RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\ + elif [ -f yarn.lock ]; then yarn build; \\ + else npm run build; fi + +# ── Stage 2: Production ───────────────────────────────────────── +FROM node:22-slim +WORKDIR /app +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/public ./public + +ENV NODE_ENV=production +EXPOSE \${port} + +CMD ["npm", "start"] +`, + + python: (port) => `FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE \${port} + +CMD ["python", "app.py"] +`, + + 'python-django': (port) => `FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN python manage.py collectstatic --noinput + +EXPOSE \${port} + +CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "config.wsgi:application"] +`, + + 'python-flask': (port) => `FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install gunicorn + +COPY . . + +EXPOSE \${port} + +CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "app:app"] +`, + + php: (port) => `FROM php:8.3-apache + +RUN a2enmod rewrite +RUN docker-php-ext-install pdo pdo_mysql mysqli + +COPY . /var/www/html/ + +RUN chown -R www-data:www-data /var/www/html + +EXPOSE 80 + +CMD ["apache2-foreground"] +`, + + 'php-laravel': (port) => `FROM php:8.3-fpm + +RUN apt-get update && apt-get install -y \\ + git curl zip unzip libpng-dev libonig-dev libxml2-dev \\ + && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www + +COPY . . + +RUN composer install --no-dev --optimize-autoloader +RUN php artisan config:cache + +EXPOSE \${port} + +CMD ["php-fpm"] +`, + + static: () => `FROM nginx:alpine + +COPY . /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +`, + + 'static-react': () => `FROM node:22-slim AS builder + +WORKDIR /app +COPY package.json pnpm-lock.yaml* yarn.lock* ./ +RUN if [ -f pnpm-lock.yaml ]; then \\ + corepack enable && corepack prepare pnpm@latest --activate && \\ + pnpm install --no-frozen-lockfile; \\ + elif [ -f yarn.lock ]; then \\ + yarn install; \\ + else \\ + npm install --legacy-peer-deps; \\ + fi +COPY . . +RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\ + elif [ -f yarn.lock ]; then yarn build; \\ + else npm run build; fi + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +`, +}; + +// ============ TEMPLATES DOCKER-COMPOSE ============ + +function generateDockerCompose(appConfig) { + const { appId, subdomain, stack, port, needsDb, dbType } = appConfig; + const domain = `${subdomain}.recette.santinova-soft.org`; + const containerPort = getContainerPort(stack, port); + + let compose = `services: + app: + build: + context: ./src + dockerfile: Dockerfile + container_name: ${appId} + restart: unless-stopped + networks: + - web`; + + // Ajouter le réseau interne si base de données + if (needsDb) { + compose += ` + - ${appId}-internal`; + } + + // Variables d'environnement + compose += ` + environment: + - NODE_ENV=production`; + + if (needsDb) { + compose += ` + - DATABASE_HOST=${appId}-db + - DATABASE_PORT=${dbType === 'postgres' ? '5432' : '3306'} + - DATABASE_NAME=${appId.replace(/-/g, '_')} + - DATABASE_USER=app_user + - DATABASE_PASSWORD=AppPassword2026! + - DB_HOST=${appId}-db + - DB_PORT=${dbType === 'postgres' ? '5432' : '3306'} + - DB_NAME=${appId.replace(/-/g, '_')} + - DB_USER=app_user + - DB_PASSWORD=AppPassword2026! + - MYSQL_HOST=${appId}-db + - MYSQL_DATABASE=${appId.replace(/-/g, '_')} + - MYSQL_USER=app_user + - MYSQL_PASSWORD=AppPassword2026!`; + } + + compose += ` + labels: + - "traefik.enable=true" + - "traefik.http.routers.${appId}.rule=Host(\`${domain}\`)" + - "traefik.http.routers.${appId}.entrypoints=websecure" + - "traefik.http.routers.${appId}.tls=true" + - "traefik.http.routers.${appId}.tls.certresolver=letsencrypt" + - "traefik.http.routers.${appId}.service=${appId}-svc" + - "traefik.http.services.${appId}-svc.loadbalancer.server.port=${containerPort}" + - "traefik.docker.network=web"`; + + // Ajouter la base de données si nécessaire + if (needsDb) { + const dbName = appId.replace(/-/g, '_'); + + if (dbType === 'postgres') { + compose += ` + + db: + image: postgres:16-alpine + container_name: ${appId}-db + restart: unless-stopped + environment: + - POSTGRES_DB=${dbName} + - POSTGRES_USER=app_user + - POSTGRES_PASSWORD=AppPassword2026! + volumes: + - ${appId}-db-data:/var/lib/postgresql/data + networks: + - ${appId}-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app_user -d ${dbName}"] + interval: 10s + timeout: 5s + retries: 5`; + } else { + compose += ` + + db: + image: mysql:8.0 + container_name: ${appId}-db + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=RootPassword2026! + - MYSQL_DATABASE=${dbName} + - MYSQL_USER=app_user + - MYSQL_PASSWORD=AppPassword2026! + volumes: + - ${appId}-db-data:/var/lib/mysql + networks: + - ${appId}-internal + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pRootPassword2026!"] + interval: 10s + timeout: 5s + retries: 5`; + } + } + + // Dépendance app -> db + if (needsDb) { + // Insert depends_on before labels + const appSection = compose.indexOf(' labels:'); + const dependsOn = ` depends_on: + db: + condition: service_healthy +`; + compose = compose.slice(0, appSection) + dependsOn + compose.slice(appSection); + } + + // Réseaux + compose += ` + +networks: + web: + external: true`; + + if (needsDb) { + compose += ` + ${appId}-internal: + driver: bridge`; + } + + // Volumes + if (needsDb) { + compose += ` + +volumes: + ${appId}-db-data:`; + } + + compose += '\n'; + + return compose; +} + +function getContainerPort(stack, customPort) { + if (customPort) return customPort; + const defaults = { + nodejs: 3000, + 'nodejs-next': 3000, + python: 5000, + 'python-django': 8000, + 'python-flask': 5000, + php: 80, + 'php-laravel': 9000, + static: 80, + 'static-react': 80, + }; + return defaults[stack] || 3000; +} + +// ============ APPS PERSISTENCE ============ + +const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json'); + +function loadDynamicApps() { + try { + if (fs.existsSync(APPS_FILE)) { + const data = fs.readFileSync(APPS_FILE, 'utf-8'); + return JSON.parse(data); + } + } catch (err) { + console.error('Erreur chargement apps dynamiques:', err.message); + } + return []; +} + +function saveDynamicApps(apps) { + try { + fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8'); + } catch (err) { + console.error('Erreur sauvegarde apps dynamiques:', err.message); + } +} + +function addDynamicApp(appDef) { + const apps = loadDynamicApps(); + // Éviter les doublons + const existing = apps.findIndex((a) => a.id === appDef.id); + if (existing >= 0) { + apps[existing] = appDef; + } else { + apps.push(appDef); + } + saveDynamicApps(apps); + + // Ajouter aussi à config.apps en mémoire + const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id); + if (existingInConfig >= 0) { + config.apps[existingInConfig] = appDef; + } else { + config.apps.push(appDef); + } +} + +function initDynamicApps() { + const dynamicApps = loadDynamicApps(); + for (const app of dynamicApps) { + const existingInConfig = config.apps.findIndex((a) => a.id === app.id); + if (existingInConfig < 0) { + config.apps.push(app); + } + } + console.log(`Apps dynamiques chargées: ${dynamicApps.length}`); +} + +// ============ CREATION D'APPLICATION ============ + +async function createApplication(params, logCallback) { + const { + name, + description, + subdomain, + stack, + port, + needsDb, + dbType, + sourceType, // 'zip' or 'gitea' + giteaRepoUrl, // URL du repo Gitea existant + zipFilePath, // Chemin du fichier ZIP uploadé + } = params; + + const appId = subdomain; + const domain = `${subdomain}.recette.santinova-soft.org`; + const appDir = path.join(config.appsBasePath, appId); + const srcDir = path.join(appDir, 'src'); + + const log = (msg) => { + const line = `[${new Date().toISOString()}] ${msg}`; + console.log(line); + if (logCallback) logCallback(line); + }; + + try { + // 1. Créer le répertoire de l'application + log(`Création du répertoire ${appDir}...`); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.mkdir(srcDir, { recursive: true }); + + let giteaRepoName = appId; + let giteaOwner = config.gitea.username; + + if (sourceType === 'gitea') { + // 2a. Cloner depuis un dépôt Gitea existant + log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`); + const cloneUrl = giteaRepoUrl.replace( + 'https://', + `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@` + ); + await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000); + log('Clonage terminé.'); + + // Extraire le nom du repo depuis l'URL + const urlParts = giteaRepoUrl.replace(/\.git$/, '').split('/'); + giteaRepoName = urlParts[urlParts.length - 1]; + if (urlParts.length >= 2) { + giteaOwner = urlParts[urlParts.length - 2]; + } + } else if (sourceType === 'zip') { + // 2b. Extraire le ZIP et créer un dépôt Gitea + log(`Extraction du fichier ZIP...`); + await execCommand(`cd ${srcDir} && unzip -o ${zipFilePath}`, 60000); + + // Vérifier si les fichiers sont dans un sous-répertoire + const entries = await fsp.readdir(srcDir); + const nonHidden = entries.filter((e) => !e.startsWith('.') && e !== '__MACOSX'); + if (nonHidden.length === 1) { + const subDir = path.join(srcDir, nonHidden[0]); + const stat = await fsp.stat(subDir); + if (stat.isDirectory()) { + log(`Déplacement des fichiers depuis le sous-répertoire ${nonHidden[0]}...`); + await execCommand(`cd ${subDir} && mv * ${srcDir}/ 2>/dev/null; mv .* ${srcDir}/ 2>/dev/null; cd ${srcDir} && rmdir "${nonHidden[0]}" 2>/dev/null || true`); + } + } + + // Supprimer le ZIP temporaire + try { await fsp.unlink(zipFilePath); } catch (e) { /* ignore */ } + + // Créer le dépôt sur Gitea + log(`Création du dépôt Gitea: ${appId}...`); + try { + await createRepo(appId, description || `Application ${name}`, true); + log('Dépôt Gitea créé.'); + } catch (err) { + if (err.response && err.response.status === 409) { + log('Le dépôt Gitea existe déjà, utilisation du dépôt existant.'); + } else { + throw err; + } + } + + // Initialiser git et pousser le code + log('Initialisation Git et push vers Gitea...'); + const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`; + await execCommand( + `cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`, + 120000 + ); + log('Code poussé sur Gitea.'); + } + + // 3. Générer le Dockerfile si nécessaire + const dockerfilePath = path.join(srcDir, 'Dockerfile'); + const hasDockerfile = fs.existsSync(dockerfilePath); + + if (!hasDockerfile) { + log(`Génération du Dockerfile pour la stack ${stack}...`); + const containerPort = getContainerPort(stack, port); + const templateFn = DOCKERFILE_TEMPLATES[stack] || DOCKERFILE_TEMPLATES['nodejs']; + let dockerfileContent = templateFn(containerPort); + + // If the project does NOT have a patches/ directory, remove COPY patches/ lines + const hasPatchesDir = fs.existsSync(path.join(srcDir, 'patches')); + if (!hasPatchesDir) { + dockerfileContent = dockerfileContent + .split('\n') + .filter(line => !line.includes('COPY patches/')) + .join('\n'); + log('Pas de dossier patches/ détecté, lignes COPY patches/ retirées du Dockerfile.'); + } + + // If the project does NOT have a drizzle/ directory, remove COPY drizzle lines + const hasDrizzleDir = fs.existsSync(path.join(srcDir, 'drizzle')); + if (!hasDrizzleDir) { + dockerfileContent = dockerfileContent + .split('\n') + .filter(line => !line.includes('COPY drizzle')) + .join('\n'); + log('Pas de dossier drizzle/ détecté, lignes COPY drizzle retirées du Dockerfile.'); + } + + await fsp.writeFile(dockerfilePath, dockerfileContent, 'utf-8'); + log('Dockerfile généré.'); + + // Commit le Dockerfile + if (sourceType === 'zip') { + await execCommand( + `cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`, + 60000 + ); + log('Dockerfile commité et poussé sur Gitea.'); + } + } else { + log('Dockerfile existant détecté, utilisation du Dockerfile du projet.'); + } + + // 4. Générer le docker-compose.yml + log('Génération du docker-compose.yml...'); + const containerPort = getContainerPort(stack, port); + const composeContent = generateDockerCompose({ + appId, + subdomain, + stack, + port: containerPort, + needsDb: needsDb || false, + dbType: dbType || 'mysql', + }); + await fsp.writeFile(path.join(appDir, 'docker-compose.yml'), composeContent, 'utf-8'); + log('docker-compose.yml généré.'); + + // 5. Lancer le build et le déploiement Docker + log('Lancement du build Docker (cela peut prendre quelques minutes)...'); + const buildResult = await execCommand( + `cd ${appDir} && docker compose build --no-cache 2>&1`, + 600000 + ); + log('Build Docker terminé.'); + + log('Démarrage des conteneurs...'); + await execCommand(`cd ${appDir} && docker compose up -d 2>&1`, 120000); + log('Conteneurs démarrés.'); + + // 6. Créer un utilisateur admin dans la base de données si nécessaire + if (needsDb) { + log('Attente du démarrage de la base de données (15s)...'); + await new Promise((resolve) => setTimeout(resolve, 15000)); + + try { + log('Tentative de création de l\'utilisateur admin dans la base de données...'); + if (dbType === 'postgres') { + await execCommand( + `docker exec ${appId}-db psql -U app_user -d ${appId.replace(/-/g, '_')} -c "CREATE TABLE IF NOT EXISTS admin_users (id SERIAL PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT NOW()); INSERT INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin') ON CONFLICT DO NOTHING;" 2>&1`, + 30000 + ); + } else { + await execCommand( + `docker exec ${appId}-db mysql -uapp_user -pAppPassword2026! ${appId.replace(/-/g, '_')} -e "CREATE TABLE IF NOT EXISTS admin_users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); INSERT IGNORE INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin');" 2>&1`, + 30000 + ); + } + log('Utilisateur admin créé dans la base de données.'); + } catch (dbErr) { + log(`Note: Création de l'utilisateur admin en BDD non critique: ${dbErr.message}`); + } + } + + // 7. Enregistrer l'application dans la configuration dynamique + const appDef = { + id: appId, + name: name, + description: description || `Application ${name}`, + directory: appId, + giteaRepo: giteaRepoName, + giteaOwner: giteaOwner, + urls: { + recette: `https://${domain}`, + prod: null, + }, + containerName: appId, + healthCheckUrl: `https://${domain}`, + port: containerPort, + stack: stack, + needsDb: needsDb || false, + dbType: dbType || null, + createdAt: new Date().toISOString(), + }; + + addDynamicApp(appDef); + log(`Application ${name} enregistrée dans le dashboard.`); + log(`Accessible sur: https://${domain}`); + + return { + success: true, + app: appDef, + message: `Application ${name} créée et déployée avec succès`, + url: `https://${domain}`, + }; + } catch (err) { + log(`ERREUR: ${err.message}`); + throw err; + } +} + +function execCommand(cmd, timeout = 60000) { + return new Promise((resolve, reject) => { + exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`)); + } else { + resolve(stdout + stderr); + } + }); + }); +} + +// ============ STACK DETECTION ============ + +function getAvailableStacks() { + return [ + { id: 'nodejs', name: 'Node.js', icon: 'nodejs', description: 'Application Node.js (Express, Fastify, etc.)' }, + { id: 'nodejs-next', name: 'Node.js (Next.js)', icon: 'nodejs', description: 'Application Next.js avec SSR' }, + { id: 'python', name: 'Python', icon: 'python', description: 'Application Python générique' }, + { id: 'python-flask', name: 'Python (Flask)', icon: 'python', description: 'Application Flask' }, + { id: 'python-django', name: 'Python (Django)', icon: 'python', description: 'Application Django' }, + { id: 'php', name: 'PHP', icon: 'php', description: 'Application PHP avec Apache' }, + { id: 'php-laravel', name: 'PHP (Laravel)', icon: 'php', description: 'Application Laravel' }, + { id: 'static', name: 'Site statique', icon: 'static', description: 'HTML/CSS/JS statique (Nginx)' }, + { id: 'static-react', name: 'React (SPA)', icon: 'react', description: 'Application React avec build (Vite/CRA)' }, + ]; +} + +module.exports = { + createApplication, + generateDockerCompose, + getAvailableStacks, + initDynamicApps, + loadDynamicApps, + addDynamicApp, + DOCKERFILE_TEMPLATES, +}; diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..bf27443 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,61 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const config = require('./config'); + +// Hash du mot de passe au démarrage +let passwordHash = null; + +async function initAuth() { + passwordHash = await bcrypt.hash(config.auth.password, 10); +} + +async function authenticate(username, password) { + if (username !== config.auth.username) { + return null; + } + // Comparer directement car on stocke le mot de passe en clair dans la config + if (password !== config.auth.password) { + return null; + } + const token = jwt.sign( + { username, role: 'admin' }, + config.jwtSecret, + { expiresIn: config.jwtExpiry } + ); + return token; +} + +function verifyToken(token) { + try { + return jwt.verify(token, config.jwtSecret); + } catch (err) { + return null; + } +} + +function authMiddleware(req, res, next) { + // Check Authorization header + let token = null; + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + // Check cookie + if (!token && req.cookies) { + token = req.cookies.dashboard_token; + } + + if (!token) { + return res.status(401).json({ error: 'Non authentifié' }); + } + + const decoded = verifyToken(token); + if (!decoded) { + return res.status(401).json({ error: 'Token invalide ou expiré' }); + } + + req.user = decoded; + next(); +} + +module.exports = { initAuth, authenticate, verifyToken, authMiddleware }; diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..e6540a1 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,230 @@ +module.exports = { + port: process.env.PORT || 3001, + jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026', + jwtExpiry: '24h', + // Authentification + auth: { + username: process.env.ADMIN_USERNAME || 'adminItinova', + password: process.env.ADMIN_PASSWORD || 'Itinova69!', + }, + // Gitea + gitea: { + url: process.env.GITEA_URL || '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', + infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure', + // Health check interval (ms) + healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000, + // Apps configuration + apps: [ + { + id: 'itinova-contacts', + name: 'Itinova Contacts', + description: 'Application de gestion des contacts Itinova', + directory: 'itinova-contacts', + giteaRepo: 'itinova-contacts', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://contacts.recette.santinova-soft.org', + prod: 'https://contacts.santinova-soft.org', + }, + containerName: 'itinova-contacts', + healthCheckUrl: 'https://contacts.recette.santinova-soft.org', + port: 3000, + category: 'ITINOVA', + status: 'production', + }, + { + id: 'itinova-podcasts', + name: 'Itinova Podcasts', + description: 'Gestionnaire de podcasts pour les établissements Itinova', + directory: 'itinova-podcasts', + giteaRepo: 'itinova-podcasts', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://podcasts.recette.santinova-soft.org', + prod: 'https://podcasts.santinova-soft.org', + }, + containerName: 'itinova-podcasts', + healthCheckUrl: 'https://podcasts.recette.santinova-soft.org', + port: 3000, + category: 'ITINOVA', + status: 'production', + }, + { + id: 'veille-reglementaire', + name: 'Veille Réglementaire', + description: 'Application de veille réglementaire et appels à projets', + directory: 'veille-reglementaire', + giteaRepo: 'veille-reglementaire', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://veille.recette.santinova-soft.org', + prod: 'https://veille.santinova-soft.org', + }, + containerName: 'veille-reglementaire-recette', + healthCheckUrl: 'https://veille.recette.santinova-soft.org', + port: 3000, + category: 'ITINOVA', + status: 'production', + }, + { + id: 'itinova-vehicle-exchange', + name: 'Itinova Gestion de Flotte', + description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements', + directory: 'itinova-vehicle-exchange', + giteaRepo: 'itinova-vehicle-exchange', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://flotte.recette.santinova-soft.org', + prod: 'https://flotte.santinova-soft.org', + }, + containerName: 'itinova-vehicle-exchange', + healthCheckUrl: 'https://flotte.recette.santinova-soft.org', + 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', + name: 'SONUM', + description: 'Cartographie des Solutions Numériques FEHAP', + directory: 'sonum', + giteaRepo: 'sonum', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://sonum.recette.santinova-soft.org', + prod: 'https://sonum.santinova-soft.org', + }, + containerName: 'sonum', + healthCheckUrl: 'https://sonum.recette.santinova-soft.org', + 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, + image: 'images/demat-facturation-dsi.jpg', + category: 'SANTINOVA', + status: 'production', + }, + { + id: 'facturation-santinova', + name: 'Facturation Santinova', + description: 'Application de gestion de la facturation Santinova', + directory: 'facturation-santinova', + giteaRepo: 'facturation-santinova', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://facturation.recette.santinova-soft.org', + prod: 'https://facturation.santinova-soft.org', + }, + containerName: 'facturation-santinova-app', + healthCheckUrl: 'https://facturation.recette.santinova-soft.org', + 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: 'INFRA', + 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', + }, + ], +}; diff --git a/backend/src/docker.js b/backend/src/docker.js new file mode 100644 index 0000000..3245e27 --- /dev/null +++ b/backend/src/docker.js @@ -0,0 +1,411 @@ +const Docker = require('dockerode'); +const { exec } = require('child_process'); +const path = require('path'); +const config = require('./config'); + +const docker = new Docker({ socketPath: '/var/run/docker.sock' }); + +/** + * Récupérer les informations d'un conteneur Docker + */ +async function getContainerInfo(containerName) { + try { + const container = docker.getContainer(containerName); + const info = await container.inspect(); + return { + id: info.Id.substring(0, 12), + name: info.Name.replace('/', ''), + state: info.State.Status, + running: info.State.Running, + startedAt: info.State.StartedAt, + image: info.Config.Image, + created: info.Created, + }; + } catch (err) { + return null; + } +} + +/** + * Récupérer les logs d'un conteneur + */ +async function getContainerLogs(containerName, tail = 100) { + try { + const container = docker.getContainer(containerName); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail: tail, + timestamps: true, + }); + // Parse buffer to string + return logs.toString('utf8'); + } catch (err) { + return `Erreur lors de la récupération des logs: ${err.message}`; + } +} + +/** + * Lister tous les conteneurs + */ +async function listContainers() { + try { + const containers = await docker.listContainers({ all: true }); + return containers.map((c) => ({ + id: c.Id.substring(0, 12), + names: c.Names.map((n) => n.replace('/', '')), + state: c.State, + status: c.Status, + image: c.Image, + created: new Date(c.Created * 1000).toISOString(), + })); + } catch (err) { + return []; + } +} + +/** + * Redéployer une application (docker compose build + up) + */ +function redeployApp(appConfig) { + return new Promise((resolve, reject) => { + const appDir = path.join(config.appsBasePath, appConfig.directory); + const cmd = `cd ${appDir} && docker compose build --no-cache && docker compose up -d`; + + const startTime = Date.now(); + const logLines = []; + + logLines.push(`[${new Date().toISOString()}] Début du redéploiement de ${appConfig.name}`); + logLines.push(`[${new Date().toISOString()}] Répertoire: ${appDir}`); + logLines.push(`[${new Date().toISOString()}] Commande: ${cmd}`); + + const process = exec(cmd, { + maxBuffer: 1024 * 1024 * 10, // 10MB + timeout: 300000, // 5 minutes + }); + + process.stdout.on('data', (data) => { + const lines = data.toString().split('\n').filter(Boolean); + lines.forEach((line) => { + logLines.push(`[${new Date().toISOString()}] ${line}`); + }); + }); + + process.stderr.on('data', (data) => { + const lines = data.toString().split('\n').filter(Boolean); + lines.forEach((line) => { + logLines.push(`[${new Date().toISOString()}] ${line}`); + }); + }); + + process.on('close', (code) => { + const duration = ((Date.now() - startTime) / 1000).toFixed(1); + if (code === 0) { + logLines.push(`[${new Date().toISOString()}] Redéploiement terminé avec succès en ${duration}s`); + resolve({ success: true, logs: logLines.join('\n'), duration }); + } else { + logLines.push(`[${new Date().toISOString()}] Redéploiement échoué (code: ${code}) en ${duration}s`); + resolve({ success: false, logs: logLines.join('\n'), duration, exitCode: code }); + } + }); + + process.on('error', (err) => { + logLines.push(`[${new Date().toISOString()}] Erreur: ${err.message}`); + resolve({ success: false, logs: logLines.join('\n'), error: err.message }); + }); + }); +} + +/** + * Pull le dernier code depuis Gitea + */ +function gitPull(appConfig) { + return new Promise((resolve, reject) => { + const srcDir = path.join(config.appsBasePath, appConfig.directory, 'src'); + const cmd = `cd ${srcDir} && git pull origin main 2>&1 || git pull origin master 2>&1`; + + exec(cmd, { timeout: 60000 }, (error, stdout, stderr) => { + if (error) { + resolve({ success: false, output: stderr || error.message }); + } else { + resolve({ success: true, output: stdout }); + } + }); + }); +} + + + +/** + * Démarrer un conteneur Docker + */ +async function startContainer(containerName) { + try { + const container = docker.getContainer(containerName); + await container.start(); + return { success: true, message: `Conteneur ${containerName} démarré` }; + } catch (err) { + return { success: false, message: err.message }; + } +} + +/** + * Arrêter un conteneur Docker + */ +async function stopContainer(containerName) { + try { + const container = docker.getContainer(containerName); + await container.stop({ t: 10 }); + return { success: true, message: `Conteneur ${containerName} arrêté` }; + } catch (err) { + return { success: false, message: err.message }; + } +} + +/** + * Redémarrer un conteneur Docker + */ +async function restartContainer(containerName) { + try { + const container = docker.getContainer(containerName); + await container.restart({ t: 10 }); + return { success: true, message: `Conteneur ${containerName} redémarré` }; + } catch (err) { + return { success: false, message: err.message }; + } +} + +/** + * Récupérer les métriques système du serveur (CPU, RAM, disque, réseau) + */ +function getServerMetrics() { + return new Promise((resolve) => { + const { execSync } = require("child_process"); + const fs = require("fs"); + try { + // ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte ===== + // 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) + const hostMetricsPath = '/opt/manus-deploy/host-metrics.json'; + if (fs.existsSync(hostMetricsPath)) { + try { + const raw = fs.readFileSync(hostMetricsPath, 'utf8'); + const hostMetrics = JSON.parse(raw); + // 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) {} + } + + // ===== Méthode 2 : Lecture directe de /proc (fallback) ===== + // CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC) + // Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible + let cpuUsage = 0; + try { + const ncpu = parseInt(execSync('nproc').toString().trim()) || 1; + const cgroupPath = '/sys/fs/cgroup/cpu.stat'; + 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) {} + + // RAM : lecture robuste multi-source + // Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose) + // Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct) + // Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique + let memTotal = 0; + let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0; + + // Fonction de lecture de meminfo depuis un chemin donné + const readMeminfo = (procPath) => { + const raw = fs.readFileSync(procPath + '/meminfo', 'utf8'); + const map = {}; + raw.split('\n').forEach(line => { + 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) {} + + // Uptime et load : utiliser /host/proc si disponible (hôte LXC réel) + const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc'; + const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' '); + const uptimeSeconds = parseFloat(uptimeRaw[0]); + const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' '); + const load1 = parseFloat(loadRaw[0]); + const load5 = parseFloat(loadRaw[1]); + const load15 = parseFloat(loadRaw[2]); + + // Réseau + let netRx = 0, netTx = 0; + try { + const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev'; + const netDev = fs.readFileSync(netDevPath, 'utf8'); + 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) {} + + resolve({ + cpu: { usage: cpuUsage }, + memory: { + total: memTotal, + used: memUsed, + free: memFree, + available: memAvailable, + buffers: memBuffers, + cached: memCached, + // usagePercent = RAM applicative réelle (hors cache disque, identique à `free`) + usagePercent: Math.round((memUsed / memTotal) * 100) + }, + disk: { + total: diskTotal, + used: diskUsed, + free: diskFree, + usagePercent: Math.round((diskUsed / diskTotal) * 100) + }, + uptime: uptimeSeconds, + load: { load1, load5, load15 }, + network: { rx: netRx, tx: netTx }, + timestamp: new Date().toISOString() + }); + } catch (err) { + resolve({ error: err.message }); + } + }); +} +module.exports = { + docker, + getContainerInfo, + getContainerLogs, + listContainers, + redeployApp, + gitPull, + startContainer, + stopContainer, + restartContainer, + getServerMetrics, +}; diff --git a/backend/src/gitea.js b/backend/src/gitea.js new file mode 100644 index 0000000..84f1d1b --- /dev/null +++ b/backend/src/gitea.js @@ -0,0 +1,119 @@ +const axios = require('axios'); +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`, + ...(config.gitea.token + ? { headers: giteaClientHeaders } + : { auth: { username: config.gitea.username, password: config.gitea.password } } + ), + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + timeout: 10000, +}); + +/** + * Récupérer les informations d'un dépôt + */ +async function getRepo(owner, repo) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}`); + return response.data; + } catch (err) { + console.error(`Erreur Gitea getRepo ${owner}/${repo}:`, err.message); + return null; + } +} + +/** + * Récupérer les derniers commits d'un dépôt + */ +async function getCommits(owner, repo, limit = 10) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/commits`, { + params: { limit, page: 1 }, + }); + return response.data.map((c) => ({ + sha: c.sha, + shortSha: c.sha.substring(0, 7), + message: c.commit.message, + author: c.commit.author.name, + date: c.commit.author.date, + url: c.html_url, + })); + } catch (err) { + console.error(`Erreur Gitea getCommits ${owner}/${repo}:`, err.message); + return []; + } +} + +/** + * Récupérer les branches d'un dépôt + */ +async function getBranches(owner, repo) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/branches`); + return response.data.map((b) => ({ + name: b.name, + commit: b.commit.id.substring(0, 7), + commitMessage: b.commit.message, + })); + } catch (err) { + console.error(`Erreur Gitea getBranches ${owner}/${repo}:`, err.message); + return []; + } +} + +/** + * Récupérer tous les dépôts (avec retry en cas d'erreur DNS) + */ +async function listRepos(retries = 3) { + for (let i = 0; i < retries; i++) { + try { + const response = await giteaClient.get('/repos/search', { + params: { limit: 50 }, + }); + return response.data.data || []; + } catch (err) { + console.error(`Erreur Gitea listRepos (tentative ${i + 1}/${retries}):`, err.message); + if (i < retries - 1) { + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + return []; +} + +/** + * Créer un nouveau dépôt + */ +async function createRepo(name, description, isPrivate = true) { + try { + const response = await giteaClient.post('/user/repos', { + name, + description, + private: isPrivate, + auto_init: true, + default_branch: 'main', + }); + return response.data; + } catch (err) { + console.error(`Erreur Gitea createRepo ${name}:`, err.message); + throw err; + } +} + +module.exports = { + getRepo, + getCommits, + getBranches, + listRepos, + createRepo, + giteaClient, +}; diff --git a/backend/src/healthcheck.js b/backend/src/healthcheck.js new file mode 100644 index 0000000..45fceaa --- /dev/null +++ b/backend/src/healthcheck.js @@ -0,0 +1,188 @@ +const axios = require('axios'); +const https = require('https'); +const config = require('./config'); +const { getContainerInfo } = require('./docker'); + +// Store pour les statuts des apps +const appStatuses = new Map(); + +// Store pour les logs de déploiement +const deploymentLogs = []; +const MAX_DEPLOYMENT_LOGS = 50; + +// Store pour les commits Gitea +const giteaCommits = new Map(); + +// Agent HTTPS qui ignore les certificats auto-signés +const httpsAgent = new https.Agent({ rejectUnauthorized: false }); + +// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed" +// (ils ont été interrompus lors du dernier redémarrage du Dashboard) +function cleanupOrphanedDeployments() { + const orphans = deploymentLogs.filter((l) => l.status === 'running'); + if (orphans.length > 0) { + console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`); + orphans.forEach((l) => { + l.status = 'failed'; + l.message = 'Déploiement interrompu (redémarrage du Dashboard)'; + l.endedAt = new Date().toISOString(); + }); + } +} + +/** + * Vérifier la santé d'une URL + */ +async function checkUrl(url) { + try { + const start = Date.now(); + const response = await axios.get(url, { + timeout: 10000, + httpsAgent, + validateStatus: (status) => status < 500, + }); + const responseTime = Date.now() - start; + return { + online: true, + statusCode: response.status, + responseTime, + }; + } catch (err) { + return { + online: false, + statusCode: null, + responseTime: null, + error: err.message, + }; + } +} + +/** + * Vérifier la santé d'une application + */ +async function checkApp(appConfig) { + const result = { + id: appConfig.id, + name: appConfig.name, + description: appConfig.description, + urls: appConfig.urls, + containerName: appConfig.containerName, + lastCheck: new Date().toISOString(), + status: 'unknown', + container: null, + health: { + recette: null, + prod: null, + }, + }; + + // Vérifier le conteneur Docker + const containerInfo = await getContainerInfo(appConfig.containerName); + result.container = containerInfo; + + // Health check recette + if (appConfig.urls.recette) { + result.health.recette = await checkUrl(appConfig.urls.recette); + } + + // Health check prod + if (appConfig.urls.prod) { + result.health.prod = await checkUrl(appConfig.urls.prod); + } + + // Déterminer le statut global + if (!containerInfo || !containerInfo.running) { + result.status = 'offline'; + } else if (result.health.recette && result.health.recette.online) { + result.status = 'online'; + } else if (containerInfo.running) { + result.status = 'error'; + } else { + result.status = 'offline'; + } + + // Stocker le résultat + appStatuses.set(appConfig.id, result); + + return result; +} + +/** + * Lancer les health checks pour toutes les apps + */ +async function checkAllApps() { + const results = []; + for (const app of config.apps) { + const result = await checkApp(app); + results.push(result); + } + return results; +} + +/** + * Ajouter un log de déploiement (retourne l'entrée créée avec son id) + */ +function addDeploymentLog(appId, log) { + const entry = { + id: Date.now().toString(), + appId, + timestamp: new Date().toISOString(), + ...log, + }; + deploymentLogs.unshift(entry); + if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) { + deploymentLogs.pop(); + } + return entry; +} + +/** + * Mettre à jour un log de déploiement existant par son id + * Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée + */ +function updateDeploymentLog(entryId, updates) { + const entry = deploymentLogs.find((l) => l.id === entryId); + if (entry) { + Object.assign(entry, updates, { updatedAt: new Date().toISOString() }); + return entry; + } + return null; +} + +/** + * Récupérer les logs de déploiement + */ +function getDeploymentLogs(appId = null) { + if (appId) { + return deploymentLogs.filter((l) => l.appId === appId); + } + return deploymentLogs; +} + +/** + * Récupérer le statut d'une app + */ +function getAppStatus(appId) { + return appStatuses.get(appId) || null; +} + +/** + * Récupérer tous les statuts + */ +function getAllStatuses() { + return Array.from(appStatuses.values()); +} + +module.exports = { + checkUrl, + checkApp, + checkAllApps, + addDeploymentLog, + updateDeploymentLog, + getDeploymentLogs, + getAppStatus, + getAllStatuses, + cleanupOrphanedDeployments, + appStatuses, + giteaCommits, +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..07b03f6 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,136 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const cookieParser = require('cookie-parser'); +const morgan = require('morgan'); +const path = require('path'); +const cron = require('node-cron'); +const http = require('http'); +const WebSocket = require('ws'); + +const config = require('./config'); +const { initAuth } = require('./auth'); +const routes = require('./routes'); +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({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, +})); +app.use(cors({ + origin: true, + credentials: true, +})); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); +app.use(cookieParser()); +app.use(morgan('combined')); + +// Route webhook montée en premier avec raw body pour la vérification HMAC +app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => { + if (req.body && Buffer.isBuffer(req.body)) { + req.rawBody = req.body; + try { req.body = JSON.parse(req.body.toString()); } catch(e) { req.body = {}; } + } + next(); +}, webhookRoutes); +// API routes +app.use('/api', routes); + +// Servir le frontend en production +const frontendPath = path.join(__dirname, '../../frontend/dist'); +app.use(express.static(frontendPath)); + +// SPA fallback +app.get('*', (req, res) => { + if (!req.path.startsWith('/api') && !req.path.startsWith('/ws')) { + res.sendFile(path.join(frontendPath, 'index.html')); + } +}); + +// Broadcast WebSocket message +global.wsBroadcast = broadcast; +function broadcast(data) { + const message = JSON.stringify(data); + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} + +// Health check périodique +async function runHealthChecks() { + try { + const results = await checkAllApps(); + broadcast({ type: 'health_update', data: results }); + + // Vérifier les nouveaux commits Gitea + for (const appConfig of config.apps) { + try { + const commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 5); + if (commits.length > 0) { + broadcast({ + type: 'gitea_commits', + data: { appId: appConfig.id, commits }, + }); + } + } catch (e) { + // Ignorer les erreurs Gitea + } + } + } catch (err) { + console.error('Erreur health check:', err.message); + } +} + +// Démarrage +async function start() { + await initAuth(); + + // Charger les applications dynamiques + initDynamicApps(); + + // Premier health check + setTimeout(runHealthChecks, 2000); + + // Health check toutes les 30 secondes + setInterval(runHealthChecks, config.healthCheckInterval); + + server.listen(config.port, '0.0.0.0', () => { + console.log(`Manus Dashboard Backend démarré sur le port ${config.port}`); + console.log(`Frontend servi depuis: ${frontendPath}`); + }); +} + +// WebSocket connection handler +wss.on('connection', (ws) => { + console.log('Nouvelle connexion WebSocket'); + + // Envoyer les statuts actuels + const statuses = getAllStatuses(); + if (statuses.length > 0) { + ws.send(JSON.stringify({ type: 'health_update', data: statuses })); + } + + ws.on('close', () => { + console.log('Connexion WebSocket fermée'); + }); +}); + +start().catch((err) => { + console.error('Erreur au démarrage:', err); + process.exit(1); +}); diff --git a/backend/src/routes.js b/backend/src/routes.js new file mode 100644 index 0000000..b4eb6d2 --- /dev/null +++ b/backend/src/routes.js @@ -0,0 +1,601 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { authenticate, authMiddleware } = require('./auth'); +const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker'); +const { getRepo, getCommits, getBranches, listRepos } = require('./gitea'); +const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck'); +const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); +const config = require('./config'); + +// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent) +cleanupOrphanedDeployments(); + +const router = express.Router(); + +// Configuration multer pour l'upload de fichiers ZIP +const uploadDir = '/tmp/dashboard-uploads'; +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const upload = multer({ + dest: uploadDir, + limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max + fileFilter: (req, file, cb) => { + if (file.mimetype === 'application/zip' || + file.mimetype === 'application/x-zip-compressed' || + file.mimetype === 'application/octet-stream' || + file.originalname.endsWith('.zip')) { + cb(null, true); + } else { + cb(new Error('Seuls les fichiers ZIP sont acceptés'), false); + } + }, +}); + +// ============ AUTH ROUTES ============ + +router.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); + } + const token = await authenticate(username, password); + if (!token) { + return res.status(401).json({ error: 'Identifiants invalides' }); + } + // Set cookie + res.cookie('dashboard_token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 24 * 60 * 60 * 1000, // 24h + }); + res.json({ token, username }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/auth/logout', (req, res) => { + res.clearCookie('dashboard_token'); + res.json({ message: 'Déconnecté' }); +}); + +router.get('/auth/me', authMiddleware, (req, res) => { + res.json({ username: req.user.username, role: req.user.role }); +}); + +// ============ APPS ROUTES ============ + +// Liste des applications avec leur statut +router.get('/apps', authMiddleware, async (req, res) => { + try { + const statuses = getAllStatuses(); + if (statuses.length === 0) { + // Première requête, lancer un check + const results = await checkAllApps(); + return res.json(results); + } + res.json(statuses); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Détail d'une application +router.get('/apps/:id', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + + const status = getAppStatus(appConfig.id); + const containerInfo = await getContainerInfo(appConfig.containerName); + + // Récupérer les infos Gitea + let giteaInfo = null; + let commits = []; + try { + giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo); + commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10); + } catch (e) { + // Gitea peut ne pas être disponible + } + + res.json({ + ...status, + container: containerInfo, + gitea: giteaInfo + ? { + fullName: giteaInfo.full_name, + description: giteaInfo.description, + defaultBranch: giteaInfo.default_branch, + updatedAt: giteaInfo.updated_at, + cloneUrl: giteaInfo.clone_url, + htmlUrl: giteaInfo.html_url, + } + : null, + commits, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Forcer un health check +router.post('/apps/:id/check', authMiddleware, async (req, res) => { + try { + const results = await checkAllApps(); + const result = results.find((r) => r.id === req.params.id); + res.json(result || { error: 'Application non trouvée' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Verrou anti-concurrence par application +const deployLocks = new Map(); +const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max + +// Nettoyage automatique des verrous bloqués toutes les minutes +setInterval(() => { + const now = Date.now(); + for (const [appId, lockTime] of deployLocks.entries()) { + if (now - lockTime > DEPLOY_TIMEOUT_MS) { + deployLocks.delete(appId); + const logs = getDeploymentLogs(appId); + logs.filter(l => l.status === 'running').forEach(l => { + l.status = 'failed'; + l.message = 'Déploiement interrompu automatiquement (timeout 10 min)'; + }); + } + } +}, 60 * 1000); + +// Redéployer une application +router.post('/apps/:id/deploy', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + + // Vérifier si un déploiement est déjà en cours pour cette app + if (deployLocks.has(appConfig.id)) { + const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000); + return res.status(409).json({ + error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`, + status: 'running' + }); + } + + // Poser le verrou + deployLocks.set(appConfig.id, Date.now()); + + // Ajouter un log de début et récupérer son id pour le mettre à jour plus tard + const deployEntry = addDeploymentLog(appConfig.id, { + status: 'running', + message: `Déploiement de ${appConfig.name} en cours...`, + logs: '', + user: req.user.username, + }); + + // Lancer le redéploiement en arrière-plan + res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' }); + + // Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed + const timeoutHandle = setTimeout(() => { + if (deployLocks.has(appConfig.id)) { + deployLocks.delete(appConfig.id); + updateDeploymentLog(deployEntry.id, { + status: 'failed', + message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`, + }); + } + }, DEPLOY_TIMEOUT_MS); + + try { + // Exécuter le redéploiement + const result = await redeployApp(appConfig); + clearTimeout(timeoutHandle); + + // Mettre à jour l'entrée existante (pas de doublon "running" + "success") + updateDeploymentLog(deployEntry.id, { + status: result.success ? 'success' : 'failed', + message: result.success + ? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s` + : `Redéploiement de ${appConfig.name} échoué`, + logs: result.logs, + duration: result.duration, + }); + + // Relancer un health check après le déploiement + setTimeout(() => checkAllApps(), 5000); + } finally { + // Toujours libérer le verrou + deployLocks.delete(appConfig.id); + } + } catch (err) { + deployLocks.delete(req.params.id); + addDeploymentLog(req.params.id, { + status: 'failed', + message: `Erreur: ${err.message}`, + logs: '', + user: req.user.username, + }); + } +}); + +// Logs d'un conteneur +router.get('/apps/:id/logs', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + const tail = parseInt(req.query.tail) || 100; + const logs = await getContainerLogs(appConfig.containerName, tail); + res.json({ logs }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ CREATE APP ROUTE ============ + +// Récupérer les stacks disponibles +router.get('/apps-config/stacks', authMiddleware, (req, res) => { + res.json(getAvailableStacks()); +}); + +// Créer une nouvelle application +router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => { + try { + const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body; + + // Validation + if (!name || !subdomain || !stack || !sourceType) { + return res.status(400).json({ + error: 'Champs requis manquants: name, subdomain, stack, sourceType', + }); + } + + // Vérifier que le subdomain est valide + const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!subdomainRegex.test(subdomain)) { + return res.status(400).json({ + error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets', + }); + } + + // Vérifier que l'app n'existe pas déjà + const existingApp = config.apps.find((a) => a.id === subdomain); + if (existingApp) { + return res.status(409).json({ + error: `Une application avec l'identifiant "${subdomain}" existe déjà`, + }); + } + + // Vérifier le fichier ZIP si sourceType === 'zip' + if (sourceType === 'zip' && !req.file) { + return res.status(400).json({ + error: 'Un fichier ZIP est requis pour le type de source "zip"', + }); + } + + // Vérifier l'URL Gitea si sourceType === 'gitea' + if (sourceType === 'gitea' && !giteaRepoUrl) { + return res.status(400).json({ + error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"', + }); + } + + // Log de début de création et récupérer l'id pour mise à jour ultérieure + const logLines = []; + const createEntry = addDeploymentLog(subdomain, { + status: 'running', + message: `Création de l'application ${name} en cours...`, + logs: '', + user: req.user.username, + }); + + // Répondre immédiatement + res.json({ + message: `Création de l'application ${name} lancée`, + status: 'running', + appId: subdomain, + }); + + // Lancer la création en arrière-plan + try { + const result = await createApplication( + { + name, + description, + subdomain, + stack, + port: port ? parseInt(port) : null, + needsDb: needsDb === 'true' || needsDb === true, + dbType: dbType || 'mysql', + sourceType, + giteaRepoUrl, + zipFilePath: req.file ? req.file.path : null, + }, + (line) => logLines.push(line) + ); + + updateDeploymentLog(createEntry.id, { + status: 'success', + message: `Application ${name} créée et déployée avec succès`, + logs: logLines.join('\n'), + }); + + // Relancer un health check + setTimeout(() => checkAllApps(), 10000); + } catch (createErr) { + updateDeploymentLog(createEntry.id, { + status: 'failed', + message: `Création de ${name} échouée: ${createErr.message}`, + logs: logLines.join('\n'), + }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ DEPLOYMENT LOGS ============ + +router.get('/deployments', authMiddleware, (req, res) => { + const appId = req.query.appId || null; + res.json(getDeploymentLogs(appId)); +}); + +// ============ GITEA ROUTES ============ + +router.get('/gitea/repos', authMiddleware, async (req, res) => { + try { + const repos = await listRepos(); + res.json( + repos.map((r) => ({ + id: r.id, + name: r.name, + fullName: r.full_name, + description: r.description, + private: r.private, + htmlUrl: r.html_url, + cloneUrl: r.clone_url, + updatedAt: r.updated_at, + language: r.language, + size: r.size, + })) + ); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => { + try { + const commits = await getCommits(req.params.owner, req.params.repo, 20); + res.json(commits); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => { + try { + const branches = await getBranches(req.params.owner, req.params.repo); + res.json(branches); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ 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 }); + } +}); + +// ============ PUBLIC APPS ROUTE (sans authentification) ============ +// Utilisé par le portail applicatif pour construire les tuiles dynamiquement +// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles +router.get('/public/apps', (req, res) => { + try { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + // getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué + // On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse + const statuses = getAllStatuses(); + const publicApps = config.apps + .filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard) + .map((a) => { + const status = statuses.find((s) => s.id === a.id); + return { + id: a.id, + name: a.name, + category: a.category || 'SANTINOVA', + image: a.image || ('images/' + a.id + '.png'), + urls: a.urls || {}, + status: status ? status.status : 'unknown', + containerRunning: status && status.container ? status.container.running : false, + }; + }); + res.json(publicApps); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ DOCKER ROUTES ============ + +router.get('/docker/containers', authMiddleware, async (req, res) => { + try { + const containers = await listContainers(); + res.json(containers); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ SYSTEM INFO ============ + +router.get('/system/info', authMiddleware, async (req, res) => { + try { + const containers = await listContainers(); + res.json({ + totalApps: config.apps.length, + totalContainers: containers.length, + runningContainers: containers.filter((c) => c.state === 'running').length, + giteaUrl: config.gitea.url, + serverTime: new Date().toISOString(), + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + + +// ============ CONTAINER CONTROL (start/stop/restart) ============ +router.post('/apps/:id/start', authMiddleware, async (req, res) => { + try { + const app = config.apps.find((a) => a.id === req.params.id); + if (!app) return res.status(404).json({ error: 'Application non trouvée' }); + const containerName = app.containerName || app.id; + const result = await startContainer(containerName); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/apps/:id/stop', authMiddleware, async (req, res) => { + try { + const app = config.apps.find((a) => a.id === req.params.id); + if (!app) return res.status(404).json({ error: 'Application non trouvée' }); + const containerName = app.containerName || app.id; + const result = await stopContainer(containerName); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/apps/:id/restart', authMiddleware, async (req, res) => { + try { + const app = config.apps.find((a) => a.id === req.params.id); + if (!app) return res.status(404).json({ error: 'Application non trouvée' }); + const containerName = app.containerName || app.id; + const result = await restartContainer(containerName); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ SERVER METRICS ============ +router.get('/system/metrics', authMiddleware, async (req, res) => { + try { + const metrics = await getServerMetrics(); + res.json(metrics); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +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_RECETTE_TOKEN || process.env.GITEA_TOKEN || null; + +// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json) +function getInventoryApps() { + return config.apps.map((a) => ({ + id: a.id, + name: a.name, + repoName: a.giteaRepo || a.id, + })); +} + +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 INVENTORY_APPS = getInventoryApps(); + 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 }); + } +}); +; diff --git a/backend/src/ssh.js b/backend/src/ssh.js new file mode 100644 index 0000000..34a3faf --- /dev/null +++ b/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/backend/src/webhook.js b/backend/src/webhook.js new file mode 100644 index 0000000..69ff5fd --- /dev/null +++ b/backend/src/webhook.js @@ -0,0 +1,143 @@ +/** + * Manus Dashboard - Webhook Handler (CI/CD Gitea) + * Route : POST /api/webhook/gitea + * Écoute les événements push de Gitea et déclenche le déploiement automatique. + */ +const express = require('express'); +const crypto = require('crypto'); +const { exec } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const router = express.Router(); + +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''; +const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps'; +const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh'; +const LOG_DIR = '/var/log/manus-deploy'; + +// Mapping dépôt Gitea -> nom du dossier application sur le serveur +const REPO_TO_APP_MAP = { + 'itinova-contacts': 'itinova-contacts', + 'itinova-podcasts': 'itinova-podcasts', + '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) +const deployingApps = new Set(); + +function verifyGiteaSignature(req) { + if (!WEBHOOK_SECRET) { + console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.'); + return true; + } + const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || ''; + console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers))); + console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT'); + console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0); + // Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser + const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body); + const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex'); + const expected = `sha256=${hmac}`; + const received = signature.startsWith('sha256=') ? signature : `sha256=${signature}`; + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); + } catch { + return false; + } +} + +function runDeploy(appName, branch, commitHash, committer, broadcast) { + if (deployingApps.has(appName)) { + console.log(`[Webhook] Déploiement déjà en cours pour ${appName}. Ignoré.`); + return; + } + deployingApps.add(appName); + console.log(`[Webhook] Déclenchement déploiement: ${appName} (branch: ${branch}, commit: ${commitHash})`); + + if (broadcast) { + broadcast({ type: 'deploy_started', data: { app: appName, commit: commitHash, branch, committer } }); + } + + const env = { ...process.env, APPS_BASE_PATH }; + const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`; + let output = ''; + + const child = exec(cmd, { env, timeout: 600000 }); + child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); }); + child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); }); + + child.on('close', (code) => { + deployingApps.delete(appName); + const status = code === 0 ? 'success' : 'failed'; + console.log(`[Webhook] Déploiement ${status} pour ${appName} (exit: ${code})`); + if (broadcast) { + broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } }); + } + try { + fs.mkdirSync(LOG_DIR, { recursive: true }); + fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({ + app: appName, branch, commit: commitHash, committer, status, exitCode: code, + timestamp: new Date().toISOString(), output: output.slice(-3000), + }, null, 2)); + } catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); } + }); +} + +// POST /api/webhook/gitea +router.post('/gitea', (req, res) => { + if (!verifyGiteaSignature(req)) { + console.warn('[Webhook] Signature invalide. Requête rejetée.'); + return res.status(401).json({ error: 'Signature invalide' }); + } + const event = req.headers['x-gitea-event'] || 'unknown'; + if (event !== 'push') { + return res.status(200).json({ message: `Événement ${event} ignoré` }); + } + const payload = req.body; + const repoName = payload?.repository?.name || ''; + const branch = (payload?.ref || '').replace('refs/heads/', ''); + const commitHash = (payload?.after || '').slice(0, 8) || 'unknown'; + const committer = payload?.pusher?.login || payload?.pusher?.name || 'unknown'; + + console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`); + + const appName = REPO_TO_APP_MAP[repoName]; + if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` }); + if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` }); + + res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer }); + + // Récupérer la fonction broadcast si disponible globalement + const broadcastFn = global.wsBroadcast || null; + setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn)); +}); + +// GET /api/webhook/status/:appName +router.get('/status/:appName', (req, res) => { + const { appName } = req.params; + const statusFile = path.join(LOG_DIR, `${appName}-last-deploy.json`); + if (!fs.existsSync(statusFile)) return res.status(404).json({ error: 'Aucun déploiement enregistré' }); + try { + const status = JSON.parse(fs.readFileSync(statusFile, 'utf8')); + res.json({ ...status, isDeploying: deployingApps.has(appName) }); + } catch { res.status(500).json({ error: 'Erreur lecture statut' }); } +}); + +// GET /api/webhook/status +router.get('/status', (req, res) => { + try { + fs.mkdirSync(LOG_DIR, { recursive: true }); + const files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('-last-deploy.json')); + const statuses = files.map(f => { + try { const d = JSON.parse(fs.readFileSync(path.join(LOG_DIR, f), 'utf8')); return { ...d, isDeploying: deployingApps.has(d.app) }; } + catch { return null; } + }).filter(Boolean); + res.json({ deployments: statuses, currentlyDeploying: [...deployingApps] }); + } catch { res.status(500).json({ error: 'Erreur lecture statuts' }); } +}); + +module.exports = router; diff --git a/docker-compose.yml b/docker-compose.yml index ad1bfdf..9b5bd8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,45 @@ services: dashboard: build: - context: ./src + context: . dockerfile: Dockerfile container_name: manus-dashboard restart: unless-stopped - privileged: true - env_file: - - .env environment: - NODE_ENV=production - PORT=3001 - - JWT_SECRET=${DASHBOARD_JWT_SECRET} - - ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} - - ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} - - GITEA_URL=${GITEA_URL} - - GITEA_USERNAME=${GITEA_USERNAME} - - GITEA_PASSWORD=${GITEA_PASSWORD} - - WEBHOOK_SECRET=${WEBHOOK_SECRET} - - APPS_BASE_PATH=${APPS_BASE_PATH} - - INFRA_BASE_PATH=${INFRA_BASE_PATH} - - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL} + - JWT_SECRET=manus-dashboard-jwt-secret-2026-recette + - ADMIN_USERNAME=adminItinova + - ADMIN_PASSWORD=Itinova69! + - GITEA_URL=https://git.recette.santinova-soft.org + - GITEA_USERNAME=manus-admin + - 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 + - HEALTH_CHECK_INTERVAL=30000 volumes: - /var/run/docker.sock:/var/run/docker.sock - /opt/manus-deploy:/opt/manus-deploy + extra_hosts: + - "git.santinova-soft.org:180.149.196.138" networks: - web labels: - "traefik.enable=true" + # Route HTTPS via dashboard.recette.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.tls=true" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc" - "traefik.http.routers.manus-dashboard.priority=100" + # Service - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.docker.network=web" + networks: web: external: true diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2cbebf5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Dashboard - Gestion des Applications + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c395304 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "manus-dashboard-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "axios": "^1.7.0", + "lucide-react": "^0.441.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "vite": "^5.4.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..15947d7 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/frontend/public/infra_schema_v2.png b/frontend/public/infra_schema_v2.png new file mode 100644 index 0000000..43fb240 Binary files /dev/null and b/frontend/public/infra_schema_v2.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..dcbd001 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import LoginPage from './pages/LoginPage'; +import DashboardPage from './pages/DashboardPage'; +import AppsPage from './pages/AppsPage'; +import MonitoringPage from './pages/MonitoringPage'; +import DeploymentsPage from './pages/DeploymentsPage'; +import GiteaPage from './pages/GiteaPage'; +import DockerPage from './pages/DockerPage'; +import DocumentationPage from './pages/DocumentationPage'; +import InventairePage from './pages/InventairePage'; +import TerminalPage from './pages/TerminalPage'; +import Sidebar from './components/Sidebar'; +import useWebSocket from './hooks/useWebSocket'; +import { getMe, getApps, logout as apiLogout } from './utils/api'; +export default function App() { + const [authenticated, setAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState('dashboard'); + const [apps, setApps] = useState([]); + // Vérifier l'authentification au chargement + useEffect(() => { + const token = localStorage.getItem('dashboard_token'); + if (token) { + getMe() + .then(() => { + setAuthenticated(true); + fetchApps(); + }) + .catch(() => { + localStorage.removeItem('dashboard_token'); + setAuthenticated(false); + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + const fetchApps = async () => { + try { + const res = await getApps(); + setApps(res.data); + } catch (err) { + console.error('Erreur chargement apps:', err); + } + }; + // WebSocket handler + const handleWsMessage = useCallback((data) => { + if (data.type === 'health_update') { + setApps(data.data); + } + }, []); + const { connected: wsConnected } = useWebSocket( + authenticated ? handleWsMessage : null + ); + const handleLogin = (data) => { + setAuthenticated(true); + fetchApps(); + }; + const handleLogout = async () => { + try { + await apiLogout(); + } catch (e) { + // ignore + } + localStorage.removeItem('dashboard_token'); + setAuthenticated(false); + setApps([]); + }; + if (loading) { + return ( +
+
+ + + + + Chargement... +
+
+ ); + } + if (!authenticated) { + return ; + } + const renderPage = () => { + switch (currentPage) { + case 'dashboard': + return ; + case 'apps': + return ; + case 'deployments': + return ; + case 'gitea': + return ; + case 'docker': + return ; + case 'monitoring': + return ; + case 'documentation': + return ; + case 'inventaire': + return ; + case 'terminal': + return ; + default: + return ; + } + }; + return ( +
+ +
{renderPage()}
+
+ ); +} diff --git a/frontend/src/components/AddAppModal.jsx b/frontend/src/components/AddAppModal.jsx new file mode 100644 index 0000000..9c17ff0 --- /dev/null +++ b/frontend/src/components/AddAppModal.jsx @@ -0,0 +1,679 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Upload, + GitBranch, + Server, + Database, + Globe, + Package, + FileText, + CheckCircle, + AlertCircle, + Loader2, + ChevronDown, +} from 'lucide-react'; +import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api'; + +const STEPS = [ + { id: 1, title: 'Informations', icon: FileText }, + { id: 2, title: 'Source', icon: GitBranch }, + { id: 3, title: 'Stack & Options', icon: Server }, + { id: 4, title: 'Confirmation', icon: CheckCircle }, +]; + +export default function AddAppModal({ isOpen, onClose, onSuccess }) { + const [currentStep, setCurrentStep] = useState(1); + const [stacks, setStacks] = useState([]); + const [giteaRepos, setGiteaRepos] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState(null); + const [errors, setErrors] = useState({}); + + const [formData, setFormData] = useState({ + name: '', + description: '', + subdomain: '', + sourceType: 'zip', + zipFile: null, + giteaRepoUrl: '', + stack: 'nodejs', + port: '', + needsDb: false, + dbType: 'mysql', + }); + + useEffect(() => { + if (isOpen) { + getAvailableStacks() + .then((res) => setStacks(res.data)) + .catch(console.error); + getGiteaRepos() + .then((res) => setGiteaRepos(res.data)) + .catch(console.error); + // Reset + setCurrentStep(1); + setSubmitResult(null); + setErrors({}); + setFormData({ + name: '', + description: '', + subdomain: '', + sourceType: 'zip', + zipFile: null, + giteaRepoUrl: '', + stack: 'nodejs', + port: '', + needsDb: false, + dbType: 'mysql', + }); + } + }, [isOpen]); + + if (!isOpen) return null; + + const updateField = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + setErrors((prev) => ({ ...prev, [field]: null })); + }; + + const autoSubdomain = (name) => { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + }; + + const handleNameChange = (value) => { + updateField('name', value); + if (!formData.subdomain || formData.subdomain === autoSubdomain(formData.name)) { + updateField('subdomain', autoSubdomain(value)); + } + }; + + const validateStep = (step) => { + const newErrors = {}; + if (step === 1) { + if (!formData.name.trim()) newErrors.name = 'Le nom est requis'; + if (!formData.subdomain.trim()) newErrors.subdomain = 'Le sous-domaine est requis'; + else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(formData.subdomain)) { + newErrors.subdomain = 'Lettres minuscules, chiffres et tirets uniquement'; + } + } + if (step === 2) { + if (formData.sourceType === 'zip' && !formData.zipFile) { + newErrors.zipFile = 'Un fichier ZIP est requis'; + } + if (formData.sourceType === 'gitea' && !formData.giteaRepoUrl) { + newErrors.giteaRepoUrl = 'Sélectionnez un dépôt Gitea'; + } + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const nextStep = () => { + if (validateStep(currentStep)) { + setCurrentStep((prev) => Math.min(prev + 1, 4)); + } + }; + + const prevStep = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + setSubmitResult(null); + + try { + const fd = new FormData(); + fd.append('name', formData.name); + fd.append('description', formData.description); + fd.append('subdomain', formData.subdomain); + fd.append('stack', formData.stack); + fd.append('sourceType', formData.sourceType); + fd.append('needsDb', formData.needsDb.toString()); + fd.append('dbType', formData.dbType); + if (formData.port) fd.append('port', formData.port); + + if (formData.sourceType === 'zip' && formData.zipFile) { + fd.append('zipFile', formData.zipFile); + } + if (formData.sourceType === 'gitea') { + fd.append('giteaRepoUrl', formData.giteaRepoUrl); + } + + const res = await createApp(fd); + setSubmitResult({ success: true, data: res.data }); + + // Rafraîchir après un délai + setTimeout(() => { + if (onSuccess) onSuccess(); + }, 3000); + } catch (err) { + setSubmitResult({ + success: false, + error: err.response?.data?.error || err.message, + }); + } finally { + setIsSubmitting(false); + } + }; + + const selectedStack = stacks.find((s) => s.id === formData.stack); + + return ( +
+ {/* Overlay */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ + Ajouter une application +

+

+ Déployez une nouvelle application sur le serveur de recette +

+
+ +
+ + {/* Steps indicator */} +
+
+ {STEPS.map((step, idx) => ( +
+
step.id + ? 'bg-green-500/20 text-green-400' + : 'text-gray-500' + }`} + > + + {step.title} +
+ {idx < STEPS.length - 1 && ( +
step.id ? 'bg-green-500' : 'bg-dark-600' + }`} + /> + )} +
+ ))} +
+
+ + {/* Content */} +
+ {/* Step 1: Informations */} + {currentStep === 1 && ( +
+
+ + handleNameChange(e.target.value)} + placeholder="Mon Application" + className={`w-full px-4 py-2.5 bg-dark-700 border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${ + errors.name ? 'border-red-500' : 'border-dark-500' + }`} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+ +