diff --git a/src b/src deleted file mode 160000 index f1f3f93..0000000 --- a/src +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f1f3f93befabe1cce72460ad9f3c3d127587bbcd diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 0000000..3b12d20 --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +README.md +*.log +frontend/node_modules +backend/node_modules diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..4274b51 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..82c8d4f --- /dev/null +++ b/src/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/src/README.md b/src/README.md new file mode 100644 index 0000000..6cf68ec --- /dev/null +++ b/src/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/src/backend/package.json b/src/backend/package.json new file mode 100644 index 0000000..d210140 --- /dev/null +++ b/src/backend/package.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/src/backend/src/app-creator.js b/src/backend/src/app-creator.js new file mode 100644 index 0000000..535aef0 --- /dev/null +++ b/src/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/src/backend/src/auth.js b/src/backend/src/auth.js new file mode 100644 index 0000000..bf27443 --- /dev/null +++ b/src/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/src/backend/src/config.js b/src/backend/src/config.js new file mode 100644 index 0000000..3a60082 --- /dev/null +++ b/src/backend/src/config.js @@ -0,0 +1,114 @@ +module.exports = { + port: process.env.PORT || 3001, + jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026', + jwtExpiry: '24h', + // Authentification + auth: { + username: process.env.ADMIN_USERNAME || 'adminItinova', + password: process.env.ADMIN_PASSWORD || 'Itinova69!', + }, + // Gitea + gitea: { + url: process.env.GITEA_URL || 'https://git.santinova-soft.org', + username: process.env.GITEA_USERNAME || 'manus-admin', + password: process.env.GITEA_PASSWORD || 'ManusGitea2026!', + }, + // Applications config + appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', + infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure', + // Health check interval (ms) + healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000, + // Apps configuration - can be extended + apps: [ + { + id: 'itinova-contacts', + name: 'Itinova Contacts', + description: 'Application de gestion des contacts', + directory: 'itinova-contacts', + giteaRepo: 'itinova-contacts', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://contacts.recette.santinova-soft.org', + prod: null, + }, + containerName: 'itinova-contacts', + healthCheckUrl: 'https://contacts.recette.santinova-soft.org', + port: 3000, + }, + { + id: 'itinova-podcasts', + name: 'Itinova Podcasts', + description: 'Application de gestion de podcasts', + directory: 'itinova-podcasts', + giteaRepo: 'itinova-podcasts', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://podcasts.recette.santinova-soft.org', + prod: null, + }, + containerName: 'itinova-podcasts', + healthCheckUrl: 'https://podcasts.recette.santinova-soft.org', + port: 3000, + }, + { + 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: null, + }, + containerName: 'veille-reglementaire', + healthCheckUrl: 'https://veille.recette.santinova-soft.org', + port: 3000, + }, + { + id: 'itinova-vehicle-exchange', + name: 'Itinova Gestion de Flotte', + description: 'Application de gestion de flotte de véhicules', + directory: 'itinova-vehicle-exchange', + giteaRepo: 'itinova-vehicle-exchange', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://flotte.recette.santinova-soft.org', + prod: null, + }, + containerName: 'itinova-vehicle-exchange', + healthCheckUrl: 'https://flotte.recette.santinova-soft.org', + port: 3000, + }, + { + id: 'sonum', + name: 'Sonum', + description: 'Application Sonum', + directory: 'sonum', + giteaRepo: 'sonum', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://sonum.recette.santinova-soft.org', + prod: null, + }, + containerName: 'sonum', + healthCheckUrl: 'https://sonum.recette.santinova-soft.org', + port: 3000, + }, + { + id: 'facturation-santinova', + name: 'Facturation Santinova', + description: 'Application de gestion de la facturation', + directory: 'facturation-santinova', + giteaRepo: 'facturation-santinova', + giteaOwner: 'manus-admin', + urls: { + recette: 'https://facturation.recette.santinova-soft.org', + prod: null, + }, + containerName: 'facturation-santinova-app', + healthCheckUrl: 'https://facturation.recette.santinova-soft.org', + port: 3001, + }, + ], +}; diff --git a/src/backend/src/docker.js b/src/backend/src/docker.js new file mode 100644 index 0000000..4b351a9 --- /dev/null +++ b/src/backend/src/docker.js @@ -0,0 +1,293 @@ +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"); + try { + // nsenter -t 1 -m lit les métriques réelles de l'hôte VPS (pas de l'hyperviseur) + const ns = "nsenter -t 1 -m --"; + + // CPU : delta /proc/stat sur 200ms + let cpuUsage = 0; + try { + const s1 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/); + execSync("sleep 0.2"); + const s2 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/); + const idle1 = parseInt(s1[4]), total1 = s1.slice(1).reduce((a,b)=>a+parseInt(b),0); + const idle2 = parseInt(s2[4]), total2 = s2.slice(1).reduce((a,b)=>a+parseInt(b),0); + const dIdle = idle2 - idle1, dTotal = total2 - total1; + cpuUsage = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0; + } catch(e) { + try { + const cpuRaw = execSync(`${ns} top -bn1 | grep 'Cpu(s)' | awk '{print $2}'`).toString().trim(); + cpuUsage = parseFloat(cpuRaw) || 0; + } catch(e2) {} + } + + // RAM : cgroup de l'hôte (fiable même en environnement VPS virtualisé) + // La RAM totale est lue depuis /sys/fs/cgroup/memory.max de l'hôte + // La RAM utilisée est calculée via docker stats (valeurs réelles du VPS) + let memTotal = 8589934592; // 8 Go par défaut + try { + const cgroupMax = execSync(`${ns} cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null`).toString().trim(); + if (cgroupMax && cgroupMax !== 'max' && !isNaN(parseInt(cgroupMax))) { + memTotal = parseInt(cgroupMax); + } + } catch(e) {} + + // RAM utilisée : somme de tous les conteneurs via docker stats + let memUsed = 0; + try { + const statsRaw = execSync("docker stats --no-stream --format '{{.MemUsage}}'").toString().trim(); + statsRaw.split('\n').forEach(line => { + const match = line.match(/([\d.]+)(\w+)\s*\//); + if (match) { + const val = parseFloat(match[1]); + const unit = match[2].toLowerCase(); + if (unit === 'gib') memUsed += val * 1024 * 1024 * 1024; + else if (unit === 'mib') memUsed += val * 1024 * 1024; + else if (unit === 'kib') memUsed += val * 1024; + else memUsed += val; + } + }); + } catch(e) {} + const memAvailable = memTotal - memUsed; + const memFree = memAvailable; + + // Disque : df sur l'hôte + const diskRaw = execSync(`${ns} df -B1 / | tail -1`).toString().trim().split(/\s+/); + const diskTotal = parseInt(diskRaw[1]); + const diskUsed = parseInt(diskRaw[2]); + const diskFree = parseInt(diskRaw[3]); + + // Uptime et load + const uptimeRaw = execSync(`${ns} cat /proc/uptime`).toString().trim().split(" "); + const uptimeSeconds = parseFloat(uptimeRaw[0]); + const loadRaw = execSync(`${ns} cat /proc/loadavg`).toString().trim().split(" "); + const load1 = parseFloat(loadRaw[0]); + const load5 = parseFloat(loadRaw[1]); + const load15 = parseFloat(loadRaw[2]); + + // Réseau + let netRx = 0, netTx = 0; + try { + const netRaw = execSync(`${ns} cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1`).toString().trim().split(/\s+/); + netRx = parseInt(netRaw[1]) || 0; + netTx = parseInt(netRaw[9]) || 0; + } catch(e) {} + + resolve({ + cpu: { usage: cpuUsage }, + memory: { + total: memTotal, + used: memUsed, + free: memFree, + available: memAvailable, + 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/src/backend/src/gitea.js b/src/backend/src/gitea.js new file mode 100644 index 0000000..c899c89 --- /dev/null +++ b/src/backend/src/gitea.js @@ -0,0 +1,114 @@ +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) +const giteaClient = axios.create({ + baseURL: `${config.gitea.url}/api/v1`, + 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/src/backend/src/healthcheck.js b/src/backend/src/healthcheck.js new file mode 100644 index 0000000..45fceaa --- /dev/null +++ b/src/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/src/backend/src/index.js b/src/backend/src/index.js new file mode 100644 index 0000000..beae15a --- /dev/null +++ b/src/backend/src/index.js @@ -0,0 +1,134 @@ +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 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' }); + +// 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/src/backend/src/routes.js b/src/backend/src/routes.js new file mode 100644 index 0000000..e83a978 --- /dev/null +++ b/src/backend/src/routes.js @@ -0,0 +1,477 @@ +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 }); + } +}); + +// ============ 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; diff --git a/src/backend/src/webhook.js b/src/backend/src/webhook.js new file mode 100644 index 0000000..6baf388 --- /dev/null +++ b/src/backend/src/webhook.js @@ -0,0 +1,142 @@ +/** + * 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', +}; + +// 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/src/docker-compose.yml b/src/docker-compose.yml new file mode 100644 index 0000000..8134de9 --- /dev/null +++ b/src/docker-compose.yml @@ -0,0 +1,40 @@ +services: + dashboard: + build: + context: . + dockerfile: Dockerfile + container_name: manus-dashboard + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3001 + - JWT_SECRET=manus-dashboard-jwt-secret-2026-recette + - ADMIN_USERNAME=adminItinova + - ADMIN_PASSWORD=Itinova69! + - GITEA_URL=https://git.santinova-soft.org + - GITEA_USERNAME=manus-admin + - GITEA_PASSWORD=ManusGitea2026! + - APPS_BASE_PATH=/opt/manus-deploy/apps + - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure + - HEALTH_CHECK_INTERVAL=30000 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/manus-deploy:/opt/manus-deploy + networks: + - web + labels: + - "traefik.enable=true" + # Route HTTPS via dashboard.santinova-soft.org + - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" + - "traefik.http.routers.manus-dashboard.entrypoints=websecure" + - "traefik.http.routers.manus-dashboard.tls=true" + - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" + - "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc" + - "traefik.http.routers.manus-dashboard.priority=100" + # Service + - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" + - "traefik.docker.network=web" + +networks: + web: + external: true diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..2091fd4 --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Manus Dashboard - Gestion des Applications + + +
+ + + diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..c395304 --- /dev/null +++ b/src/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/src/frontend/postcss.config.js b/src/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/src/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/frontend/public/favicon.svg b/src/frontend/public/favicon.svg new file mode 100644 index 0000000..15947d7 --- /dev/null +++ b/src/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx new file mode 100644 index 0000000..9c415b8 --- /dev/null +++ b/src/frontend/src/App.jsx @@ -0,0 +1,134 @@ +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 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 ; + default: + return ; + } + }; + + return ( +
+ +
{renderPage()}
+
+ ); +} diff --git a/src/frontend/src/components/AddAppModal.jsx b/src/frontend/src/components/AddAppModal.jsx new file mode 100644 index 0000000..9c17ff0 --- /dev/null +++ b/src/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}

+ )} +
+ +
+ +