feat: aligner le dashboard production sur recette
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.log
|
||||
frontend/node_modules
|
||||
backend/node_modules
|
||||
@@ -13,13 +13,11 @@ jobs:
|
||||
runs-on: ci-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Installer et tester le backend
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
|
||||
- name: Installer et construire le frontend
|
||||
working-directory: src/frontend
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
.deployment-request
|
||||
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -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"]
|
||||
32
README.md
Normal file
32
README.md
Normal file
@@ -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!
|
||||
7
app.json
7
app.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"id": "manus-dashboard",
|
||||
"name": "Dashboard Production",
|
||||
"name": "Dashboard Recette",
|
||||
"category": "INFRA",
|
||||
"urls": {"recette": "https://dashboard.recette.santinova-soft.org", "prod": "https://dashboard.santinova-soft.org"},
|
||||
"urls": {
|
||||
"recette": "https://dashboard.recette.santinova-soft.org",
|
||||
"prod": "https://dashboard.santinova-soft.org"
|
||||
},
|
||||
"containerName": "manus-dashboard",
|
||||
"image": "images/dashboard.png",
|
||||
"giteaRepo": "manus-dashboard",
|
||||
|
||||
30
backend/package.json
Normal file
30
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
736
backend/src/app-creator.js
Normal file
736
backend/src/app-creator.js
Normal file
@@ -0,0 +1,736 @@
|
||||
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() {
|
||||
// 1. Charger les apps depuis .dashboard-apps.json (legacy)
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 2. Scanner les dossiers app.json (découverte automatique)
|
||||
try {
|
||||
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||
const entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
|
||||
let scanned = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const appJsonPath = path.join(appsBasePath, entry.name, 'app.json');
|
||||
if (!fs.existsSync(appJsonPath)) continue;
|
||||
try {
|
||||
const appDef = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
|
||||
if (!appDef.id) continue;
|
||||
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existingInConfig < 0) {
|
||||
config.apps.push(appDef);
|
||||
scanned++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Erreur lecture app.json dans ${entry.name}:`, e.message);
|
||||
}
|
||||
}
|
||||
console.log(`Apps découvertes via app.json: ${scanned}`);
|
||||
} catch (err) {
|
||||
console.error('Erreur scan app.json:', err.message);
|
||||
}
|
||||
console.log(`Total apps chargées: ${config.apps.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,
|
||||
};
|
||||
61
backend/src/auth.js
Normal file
61
backend/src/auth.js
Normal file
@@ -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 };
|
||||
248
backend/src/config.js
Normal file
248
backend/src/config.js
Normal file
@@ -0,0 +1,248 @@
|
||||
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: 'gestion-at-v2',
|
||||
name: 'Gestion des AT',
|
||||
description: 'Application de gestion des accidents du travail Itinova',
|
||||
directory: 'gestion-at-v2',
|
||||
giteaRepo: 'gestion-at-v2',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://at.recette.santinova-soft.org',
|
||||
prod: 'https://at.santinova-soft.org',
|
||||
},
|
||||
containerName: 'gestion-at',
|
||||
healthCheckUrl: 'https://at.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
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',
|
||||
image: 'images/pilotage-ms.png',
|
||||
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,
|
||||
category: 'SANTINOVA',
|
||||
image: 'images/demat-facturation-dsi.jpg',
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
438
backend/src/docker.js
Normal file
438
backend/src/docker.js
Normal file
@@ -0,0 +1,438 @@
|
||||
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, appId) {
|
||||
try {
|
||||
// Tenter d'abord un docker start (conteneur existant mais arrêté)
|
||||
const container = docker.getContainer(containerName);
|
||||
const info = await container.inspect().catch(() => null);
|
||||
if (info) {
|
||||
// Le conteneur existe, on le démarre
|
||||
if (!info.State.Running) {
|
||||
await container.start();
|
||||
}
|
||||
return { success: true, message: `Conteneur ${containerName} démarré` };
|
||||
}
|
||||
// Le conteneur n'existe pas : docker compose up dans le répertoire de l'app
|
||||
const id = appId || containerName;
|
||||
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||
const appDir = path.join(appsBasePath, id);
|
||||
const fs = require('fs');
|
||||
const composeFile = fs.existsSync(path.join(appDir, 'docker-compose.yml'))
|
||||
? path.join(appDir, 'docker-compose.yml')
|
||||
: fs.existsSync(path.join(appDir, 'docker-compose.prod.yml'))
|
||||
? path.join(appDir, 'docker-compose.prod.yml')
|
||||
: null;
|
||||
if (!composeFile) {
|
||||
return { success: false, message: `Aucun docker-compose.yml trouvé dans ${appDir}` };
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(`docker compose -f ${composeFile} up -d`, { cwd: appDir }, (err, stdout, stderr) => {
|
||||
if (err) reject(new Error(stderr || err.message));
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
return { success: true, message: `Conteneur ${containerName} créé et démarré via docker compose` };
|
||||
} 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,
|
||||
};
|
||||
119
backend/src/gitea.js
Normal file
119
backend/src/gitea.js
Normal file
@@ -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,
|
||||
};
|
||||
188
backend/src/healthcheck.js
Normal file
188
backend/src/healthcheck.js
Normal file
@@ -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,
|
||||
};
|
||||
137
backend/src/index.js
Normal file
137
backend/src/index.js
Normal file
@@ -0,0 +1,137 @@
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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);
|
||||
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(morgan('combined'));
|
||||
// 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);
|
||||
});
|
||||
601
backend/src/routes.js
Normal file
601
backend/src/routes.js
Normal file
@@ -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, app.id);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
;
|
||||
179
backend/src/ssh.js
Normal file
179
backend/src/ssh.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Module SSH Terminal - Gestion des connexions SSH via WebSocket
|
||||
* Utilise la bibliothèque ssh2 pour établir des connexions SSH
|
||||
*/
|
||||
const { Client } = require('ssh2');
|
||||
const WebSocket = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('./config');
|
||||
|
||||
/**
|
||||
* Initialise le serveur WebSocket SSH sur /ws-ssh
|
||||
* @param {http.Server} server - Le serveur HTTP Express
|
||||
*/
|
||||
function initSSHWebSocket(server) {
|
||||
const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' });
|
||||
|
||||
wssSsh.on('connection', (ws, req) => {
|
||||
// Vérifier l'authentification via le token dans l'URL
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(token, config.jwtSecret);
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Nouvelle connexion WebSocket SSH');
|
||||
|
||||
let sshClient = null;
|
||||
let sshStream = null;
|
||||
let connected = false;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connect':
|
||||
// Établir la connexion SSH
|
||||
handleConnect(ws, msg, (client, stream) => {
|
||||
sshClient = client;
|
||||
sshStream = stream;
|
||||
connected = true;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
// Envoyer des données au terminal SSH
|
||||
if (sshStream && connected) {
|
||||
sshStream.write(msg.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
// Redimensionner le terminal
|
||||
if (sshStream && connected) {
|
||||
sshStream.setWindow(msg.rows, msg.cols, 0, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
// Fermer la connexion SSH
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Type de message SSH inconnu:', msg.type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur parsing message SSH:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Connexion WebSocket SSH fermée');
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('Erreur WebSocket SSH:', err.message);
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Serveur WebSocket SSH initialisé sur /ws-ssh');
|
||||
return wssSsh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère la connexion SSH
|
||||
*/
|
||||
function handleConnect(ws, msg, onConnected) {
|
||||
const { host, port, username, password, privateKey } = msg;
|
||||
|
||||
if (!host || !username) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sshClient = new Client();
|
||||
|
||||
const connConfig = {
|
||||
host: host,
|
||||
port: port || 22,
|
||||
username: username,
|
||||
readyTimeout: 15000,
|
||||
keepaliveInterval: 10000,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connConfig.privateKey = privateKey;
|
||||
} else if (password) {
|
||||
connConfig.password = password;
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` }));
|
||||
|
||||
sshClient.on('ready', () => {
|
||||
ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` }));
|
||||
|
||||
sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => {
|
||||
if (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` }));
|
||||
sshClient.end();
|
||||
return;
|
||||
}
|
||||
|
||||
onConnected(sshClient, stream);
|
||||
|
||||
// Transmettre les données du terminal SSH vers le WebSocket
|
||||
stream.on('data', (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' }));
|
||||
sshClient.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sshClient.on('error', (err) => {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` }));
|
||||
});
|
||||
|
||||
sshClient.on('end', () => {
|
||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' }));
|
||||
});
|
||||
|
||||
sshClient.connect(connConfig);
|
||||
}
|
||||
|
||||
module.exports = { initSSHWebSocket };
|
||||
146
backend/src/webhook.js
Normal file
146
backend/src/webhook.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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',
|
||||
'gestion-at-v2': 'gestion-at-v2',
|
||||
'itinova-podcasts': 'itinova-podcasts',
|
||||
'veille-reglementaire': 'veille-reglementaire',
|
||||
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
|
||||
'manus-dashboard': 'manus-dashboard',
|
||||
'itinova-budget-si': 'itinova-budget-si',
|
||||
'facturation-santinova': 'facturation-santinova',
|
||||
'demat-facturation-dsi': 'demat-facturation-dsi',
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -20,7 +20,8 @@ services:
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_USERNAME=${GITEA_USERNAME}
|
||||
- GITEA_PASSWORD=${GITEA_PASSWORD}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis}
|
||||
- GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
|
||||
- GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
|
||||
- GITEA_PASSWORD_PROD=${GITEA_PASSWORD}
|
||||
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard Recette - Gestion des Applications</title>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3012
frontend/package-lock.json
generated
Normal file
3012
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#2563eb"/>
|
||||
<text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
BIN
frontend/public/infra_schema_v2.png
Normal file
BIN
frontend/public/infra_schema_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
132
frontend/src/App.jsx
Normal file
132
frontend/src/App.jsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="animate-spin h-8 w-8 text-primary-500" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-400">Chargement...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={handleLogin} />;
|
||||
}
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage apps={apps} />;
|
||||
case 'apps':
|
||||
return <AppsPage apps={apps} onRefresh={fetchApps} />;
|
||||
case 'deployments':
|
||||
return <DeploymentsPage />;
|
||||
case 'gitea':
|
||||
return <GiteaPage />;
|
||||
case 'docker':
|
||||
return <DockerPage />;
|
||||
case 'monitoring':
|
||||
return <MonitoringPage />;
|
||||
case 'documentation':
|
||||
return <DocumentationPage />;
|
||||
case 'inventaire':
|
||||
return <InventairePage />;
|
||||
case 'terminal':
|
||||
return <TerminalPage />;
|
||||
default:
|
||||
return <DashboardPage apps={apps} />;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950">
|
||||
<Sidebar
|
||||
currentPage={currentPage}
|
||||
onNavigate={setCurrentPage}
|
||||
onLogout={handleLogout}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
<main className="ml-64 p-8">{renderPage()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
679
frontend/src/components/AddAppModal.jsx
Normal file
679
frontend/src/components/AddAppModal.jsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-dark-800 border border-dark-600 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-primary-400" />
|
||||
Ajouter une application
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
Déployez une nouvelle application sur le serveur de recette
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="px-6 py-3 border-b border-dark-600 bg-dark-900/50">
|
||||
<div className="flex items-center justify-between">
|
||||
{STEPS.map((step, idx) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<step.icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{step.title}</span>
|
||||
</div>
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`w-8 h-0.5 mx-1 ${
|
||||
currentStep > step.id ? 'bg-green-500' : 'bg-dark-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5 overflow-y-auto max-h-[55vh]">
|
||||
{/* Step 1: Informations */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Nom de l'application <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Description de l'application..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2.5 bg-dark-700 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Sous-domaine <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-0">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subdomain}
|
||||
onChange={(e) => updateField('subdomain', e.target.value.toLowerCase())}
|
||||
placeholder="mon-app"
|
||||
className={`flex-1 px-4 py-2.5 bg-dark-700 border rounded-l-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||
errors.subdomain ? 'border-red-500' : 'border-dark-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="px-3 py-2.5 bg-dark-600 border border-dark-500 border-l-0 rounded-r-lg text-gray-400 text-sm whitespace-nowrap">
|
||||
.recette.santinova-soft.org
|
||||
</span>
|
||||
</div>
|
||||
{errors.subdomain && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.subdomain}</p>
|
||||
)}
|
||||
{formData.subdomain && !errors.subdomain && (
|
||||
<p className="text-gray-500 text-xs mt-1 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
URL: https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Source */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||
Source du code
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => updateField('sourceType', 'zip')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
formData.sourceType === 'zip'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`w-6 h-6 mb-2 ${formData.sourceType === 'zip' ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`font-medium ${formData.sourceType === 'zip' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Upload ZIP
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Uploadez une archive ZIP contenant les sources
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateField('sourceType', 'gitea')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
formData.sourceType === 'gitea'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className={`w-6 h-6 mb-2 ${formData.sourceType === 'gitea' ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`font-medium ${formData.sourceType === 'gitea' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Dépôt Gitea
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Utilisez un dépôt Gitea existant
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.sourceType === 'zip' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Fichier ZIP <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
formData.zipFile
|
||||
? 'border-green-500/50 bg-green-500/5'
|
||||
: errors.zipFile
|
||||
? 'border-red-500/50 bg-red-500/5'
|
||||
: 'border-dark-500 hover:border-dark-400 bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => updateField('zipFile', e.target.files[0])}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
{formData.zipFile ? (
|
||||
<div>
|
||||
<CheckCircle className="w-8 h-8 text-green-400 mx-auto mb-2" />
|
||||
<p className="text-green-400 font-medium">{formData.zipFile.name}</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
{(formData.zipFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Upload className="w-8 h-8 text-gray-500 mx-auto mb-2" />
|
||||
<p className="text-gray-400">Cliquez ou glissez un fichier ZIP ici</p>
|
||||
<p className="text-gray-600 text-sm mt-1">Maximum 500 MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.zipFile && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.zipFile}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.sourceType === 'gitea' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Dépôt Gitea <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{giteaRepos.length === 0 ? (
|
||||
<div className="p-4 bg-dark-700 rounded-lg text-center text-gray-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
Chargement des dépôts...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{giteaRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => updateField('giteaRepoUrl', repo.cloneUrl)}
|
||||
className={`w-full p-3 rounded-lg border text-left transition-all ${
|
||||
formData.giteaRepoUrl === repo.cloneUrl
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-white text-sm">{repo.name}</div>
|
||||
{repo.description && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">{repo.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{repo.language && (
|
||||
<span className="text-xs px-2 py-0.5 bg-dark-600 rounded text-gray-400">
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.giteaRepoUrl && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.giteaRepoUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Stack & Options */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||
Type de stack <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{stacks.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => updateField('stack', s.id)}
|
||||
className={`p-3 rounded-lg border text-center transition-all ${
|
||||
formData.stack === s.id
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-medium ${formData.stack === s.id ? 'text-white' : 'text-gray-300'}`}>
|
||||
{s.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedStack && (
|
||||
<p className="text-gray-500 text-xs mt-2">{selectedStack.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Port de l'application (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) => updateField('port', e.target.value)}
|
||||
placeholder="Auto-détecté selon la stack"
|
||||
className="w-full px-4 py-2.5 bg-dark-700 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-dark-500 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className={`w-5 h-5 ${formData.needsDb ? 'text-primary-400' : 'text-gray-500'}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Base de données</div>
|
||||
<div className="text-xs text-gray-500">Ajouter un conteneur de base de données</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateField('needsDb', !formData.needsDb)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
formData.needsDb ? 'bg-primary-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
formData.needsDb ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.needsDb && (
|
||||
<div className="mt-4 pt-4 border-t border-dark-500">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Type de base de données
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => updateField('dbType', 'mysql')}
|
||||
className={`p-3 rounded-lg border text-center transition-all ${
|
||||
formData.dbType === 'mysql'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-medium ${formData.dbType === 'mysql' ? 'text-white' : 'text-gray-300'}`}>
|
||||
MySQL 8.0
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateField('dbType', 'postgres')}
|
||||
className={`p-3 rounded-lg border text-center transition-all ${
|
||||
formData.dbType === 'postgres'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-medium ${formData.dbType === 'postgres' ? 'text-white' : 'text-gray-300'}`}>
|
||||
PostgreSQL 16
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Confirmation */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-5">
|
||||
{submitResult ? (
|
||||
<div
|
||||
className={`p-6 rounded-xl border ${
|
||||
submitResult.success
|
||||
? 'border-green-500/30 bg-green-500/10'
|
||||
: 'border-red-500/30 bg-red-500/10'
|
||||
}`}
|
||||
>
|
||||
{submitResult.success ? (
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-bold text-green-400">
|
||||
Création lancée avec succès !
|
||||
</h3>
|
||||
<p className="text-gray-400 mt-2 text-sm">
|
||||
L'application est en cours de déploiement. Suivez la progression dans l'onglet Déploiements.
|
||||
</p>
|
||||
<p className="text-gray-500 mt-2 text-xs">
|
||||
URL: https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-bold text-red-400">Erreur</h3>
|
||||
<p className="text-gray-400 mt-2 text-sm">{submitResult.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-white">Récapitulatif</h3>
|
||||
<div className="bg-dark-700 rounded-xl divide-y divide-dark-600">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Nom</span>
|
||||
<span className="text-white font-medium">{formData.name}</span>
|
||||
</div>
|
||||
{formData.description && (
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Description</span>
|
||||
<span className="text-white text-sm text-right max-w-xs truncate">{formData.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">URL</span>
|
||||
<span className="text-primary-400 text-sm">
|
||||
https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Source</span>
|
||||
<span className="text-white text-sm">
|
||||
{formData.sourceType === 'zip'
|
||||
? `ZIP: ${formData.zipFile?.name || '-'}`
|
||||
: `Gitea: ${formData.giteaRepoUrl.split('/').pop()}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Stack</span>
|
||||
<span className="text-white text-sm">
|
||||
{selectedStack?.name || formData.stack}
|
||||
</span>
|
||||
</div>
|
||||
{formData.port && (
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Port</span>
|
||||
<span className="text-white text-sm">{formData.port}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<span className="text-gray-400 text-sm">Base de données</span>
|
||||
<span className="text-white text-sm">
|
||||
{formData.needsDb
|
||||
? formData.dbType === 'mysql'
|
||||
? 'MySQL 8.0'
|
||||
: 'PostgreSQL 16'
|
||||
: 'Aucune'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-yellow-400 text-sm font-medium">
|
||||
Le déploiement peut prendre plusieurs minutes
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Le build Docker et le démarrage des conteneurs seront exécutés en arrière-plan.
|
||||
Vous pourrez suivre la progression dans l'onglet Déploiements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-dark-600 bg-dark-900/50">
|
||||
<div>
|
||||
{currentStep > 1 && !submitResult && (
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className="px-4 py-2 text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{submitResult ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-primary px-6"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
) : currentStep < 4 ? (
|
||||
<button onClick={nextStep} className="btn-primary px-6">
|
||||
Suivant
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="btn-primary px-6 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Création en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Package className="w-4 h-4" />
|
||||
Créer et déployer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
frontend/src/components/AppCard.jsx
Normal file
372
frontend/src/components/AppCard.jsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatResponseTime(ms) {
|
||||
if (!ms) return 'N/A';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function AppCard({ app, commits, onRefresh }) {
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [containerLogs, setContainerLogs] = useState('');
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
const [deployMessage, setDeployMessage] = useState(null);
|
||||
const [loadingControl, setLoadingControl] = useState(null);
|
||||
|
||||
const handleContainerControl = async (action) => {
|
||||
setLoadingControl(action);
|
||||
try {
|
||||
if (action === 'start') await startApp(app.id);
|
||||
else if (action === 'stop') await stopApp(app.id);
|
||||
else if (action === 'restart') await restartApp(app.id);
|
||||
setTimeout(() => { if (onRefresh) onRefresh(); }, 2000);
|
||||
} catch (err) {
|
||||
console.error('Erreur controle conteneur:', err);
|
||||
} finally {
|
||||
setLoadingControl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!confirm(`Voulez-vous redéployer ${app.name} ?`)) return;
|
||||
setDeploying(true);
|
||||
setDeployMessage(null);
|
||||
try {
|
||||
const res = await deployApp(app.id);
|
||||
setDeployMessage({
|
||||
type: 'info',
|
||||
text: res.data.message || 'Redéploiement lancé...',
|
||||
});
|
||||
// Rafraîchir après un délai
|
||||
setTimeout(() => {
|
||||
if (onRefresh) onRefresh();
|
||||
setDeploying(false);
|
||||
}, 10000);
|
||||
} catch (err) {
|
||||
setDeployMessage({
|
||||
type: 'error',
|
||||
text: err.response?.data?.error || 'Erreur lors du redéploiement',
|
||||
});
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
await checkApp(app.id);
|
||||
if (onRefresh) onRefresh();
|
||||
} catch (err) {
|
||||
console.error('Erreur health check:', err);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
if (showLogs) {
|
||||
setShowLogs(false);
|
||||
return;
|
||||
}
|
||||
setLoadingLogs(true);
|
||||
setShowLogs(true);
|
||||
try {
|
||||
const res = await getAppLogs(app.id, 50);
|
||||
setContainerLogs(res.data.logs || 'Aucun log disponible');
|
||||
} catch (err) {
|
||||
setContainerLogs('Erreur lors de la récupération des logs');
|
||||
} finally {
|
||||
setLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const latestCommit = commits && commits.length > 0 ? commits[0] : null;
|
||||
|
||||
// Apps d'infrastructure : portail et dashboard
|
||||
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
|
||||
|
||||
return (
|
||||
<div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
|
||||
{/* Badge infra */}
|
||||
{isInfra && (
|
||||
<div className="px-6 pt-3 pb-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||
⚙ Infrastructure
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-dark-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{app.name}</h3>
|
||||
<StatusBadge status={app.status} />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{app.description || app.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={checking}
|
||||
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1.5"
|
||||
title="Vérifier l'état"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Vérifier
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5"
|
||||
title="Redéployer l'application"
|
||||
>
|
||||
<Rocket
|
||||
className={`w-3.5 h-3.5 ${deploying ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{deploying ? 'Déploiement...' : 'Redéployer'}
|
||||
</button>
|
||||
{/* Boutons contrôle conteneur */}
|
||||
<div className="flex items-center gap-1 ml-1 border-l border-dark-600 pl-2">
|
||||
<button
|
||||
onClick={() => handleContainerControl('start')}
|
||||
disabled={loadingControl !== null || app.container?.state === 'running'}
|
||||
title="Démarrer le conteneur"
|
||||
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 border border-emerald-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{loadingControl === 'start' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleContainerControl('stop')}
|
||||
disabled={loadingControl !== null || app.container?.state !== 'running'}
|
||||
title="Arrêter le conteneur"
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 border border-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{loadingControl === 'stop' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Square className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleContainerControl('restart')}
|
||||
disabled={loadingControl !== null}
|
||||
title="Redémarrer le conteneur"
|
||||
className="p-1.5 rounded-lg text-amber-400 hover:bg-amber-500/10 border border-amber-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{loadingControl === 'restart' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deploy message */}
|
||||
{deployMessage && (
|
||||
<div
|
||||
className={`px-6 py-3 text-sm flex items-center gap-2 ${
|
||||
deployMessage.type === 'error'
|
||||
? 'bg-red-500/10 text-red-400'
|
||||
: 'bg-primary-500/10 text-primary-400'
|
||||
}`}
|
||||
>
|
||||
{deployMessage.type === 'error' ? (
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
) : (
|
||||
<Rocket className="w-4 h-4" />
|
||||
)}
|
||||
{deployMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* URLs */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
URLs
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{app.urls?.recette && (
|
||||
<a
|
||||
href={app.urls.recette}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Recette
|
||||
</a>
|
||||
)}
|
||||
{app.urls?.prod ? (
|
||||
<a
|
||||
href={app.urls.prod}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Production
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600">Pas de prod</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Conteneur
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
{app.container ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">ID:</span>{' '}
|
||||
<code className="text-xs bg-dark-700 px-1.5 py-0.5 rounded">
|
||||
{app.container.id}
|
||||
</code>
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">État:</span>{' '}
|
||||
{app.container.state}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Non trouvé</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Santé
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{app.health?.recette && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity
|
||||
className={`w-3.5 h-3.5 ${
|
||||
app.health.recette.online
|
||||
? 'text-emerald-400'
|
||||
: 'text-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{app.health.recette.online ? (
|
||||
<>
|
||||
HTTP {app.health.recette.statusCode} —{' '}
|
||||
{formatResponseTime(app.health.recette.responseTime)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">
|
||||
{app.health.recette.error || 'Inaccessible'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-600">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{formatDate(app.lastCheck)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Commit */}
|
||||
{latestCommit && (
|
||||
<div className="px-6 py-3 border-t border-dark-700 bg-dark-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dernier commit
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<code className="text-xs bg-primary-500/10 text-primary-400 px-2 py-0.5 rounded font-mono">
|
||||
{latestCommit.shortSha}
|
||||
</code>
|
||||
<span className="text-sm text-gray-300 truncate flex-1">
|
||||
{latestCommit.message}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">
|
||||
{latestCommit.author} — {formatDate(latestCommit.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Toggle */}
|
||||
<div className="border-t border-dark-700">
|
||||
<button
|
||||
onClick={handleShowLogs}
|
||||
className="w-full px-6 py-3 flex items-center justify-between text-sm text-gray-400 hover:text-gray-300 hover:bg-dark-700/50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Logs du conteneur
|
||||
</span>
|
||||
{showLogs ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{showLogs && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-dark-950 rounded-lg p-4 max-h-64 overflow-auto">
|
||||
{loadingLogs ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Chargement des logs...
|
||||
</div>
|
||||
) : (
|
||||
<pre className="log-viewer text-xs text-gray-400">
|
||||
{containerLogs}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/Sidebar.jsx
Normal file
98
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { LayoutList, TerminalSquare,
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
GitBranch,
|
||||
ScrollText,
|
||||
Container,
|
||||
LogOut,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Activity,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'apps', label: 'Applications', icon: Box },
|
||||
{ id: 'deployments', label: 'Déploiements', icon: ScrollText },
|
||||
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
|
||||
{ id: 'docker', label: 'Docker', icon: Container },
|
||||
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
|
||||
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
|
||||
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
|
||||
{ id: 'terminal', label: 'Terminal SSH', icon: TerminalSquare },
|
||||
];
|
||||
export default function Sidebar({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onLogout,
|
||||
wsConnected,
|
||||
}) {
|
||||
return (
|
||||
<aside className="w-64 bg-dark-900 border-r border-dark-700 flex flex-col h-screen fixed left-0 top-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-600 flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
||||
<p className="text-xs text-gray-500">Recette</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
const isDoc = item.id === 'documentation';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? isDoc
|
||||
? 'bg-purple-600/10 text-purple-400 border border-purple-500/20'
|
||||
: 'bg-primary-600/10 text-primary-400 border border-primary-500/20'
|
||||
: isDoc
|
||||
? 'text-purple-400/70 hover:text-purple-300 hover:bg-purple-500/10'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-700 space-y-3">
|
||||
{/* WebSocket status */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-dark-800">
|
||||
{wsConnected ? (
|
||||
<>
|
||||
<Wifi className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs text-emerald-400">Temps réel actif</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs text-gray-500">Hors ligne</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4.5 h-4.5" />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/StatusBadge.jsx
Normal file
54
frontend/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
const statusConfig = {
|
||||
online: {
|
||||
label: 'En ligne',
|
||||
className: 'status-online',
|
||||
dotColor: 'bg-emerald-400',
|
||||
},
|
||||
offline: {
|
||||
label: 'Hors ligne',
|
||||
className: 'status-offline',
|
||||
dotColor: 'bg-gray-400',
|
||||
},
|
||||
error: {
|
||||
label: 'Erreur',
|
||||
className: 'status-error',
|
||||
dotColor: 'bg-red-400',
|
||||
},
|
||||
unknown: {
|
||||
label: 'Inconnu',
|
||||
className: 'status-warning',
|
||||
dotColor: 'bg-amber-400',
|
||||
},
|
||||
running: {
|
||||
label: 'En cours',
|
||||
className: 'status-warning',
|
||||
dotColor: 'bg-amber-400',
|
||||
},
|
||||
success: {
|
||||
label: 'Succès',
|
||||
className: 'status-online',
|
||||
dotColor: 'bg-emerald-400',
|
||||
},
|
||||
failed: {
|
||||
label: 'Échoué',
|
||||
className: 'status-error',
|
||||
dotColor: 'bg-red-400',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = 'sm' }) {
|
||||
const config = statusConfig[status] || statusConfig.unknown;
|
||||
|
||||
return (
|
||||
<span className={config.className}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${config.dotColor} mr-1.5 ${
|
||||
status === 'online' || status === 'running' ? 'animate-pulse-dot' : ''
|
||||
}`}
|
||||
/>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/useWebSocket.js
Normal file
55
frontend/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export default function useWebSocket(onMessage) {
|
||||
const wsRef = useRef(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const reconnectTimeout = useRef(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
console.log('WebSocket connecté');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (onMessage) onMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Erreur parsing WS message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
console.log('WebSocket déconnecté, reconnexion dans 5s...');
|
||||
reconnectTimeout.current = setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('Erreur WebSocket:', err);
|
||||
ws.close();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Erreur connexion WebSocket:', err);
|
||||
reconnectTimeout.current = setTimeout(connect, 5000);
|
||||
}
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
96
frontend/src/index.css
Normal file
96
frontend/src/index.css
Normal file
@@ -0,0 +1,96 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-dark-950 text-gray-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-dark-900 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-dark-900 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-dark-700 hover:bg-dark-600 text-gray-200 font-medium py-2 px-4 rounded-lg transition-colors duration-200 border border-dark-600 focus:outline-none focus:ring-2 focus:ring-dark-500 focus:ring-offset-2 focus:ring-offset-dark-900;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-dark-800 border border-dark-700 rounded-xl shadow-lg;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full bg-dark-700 border border-dark-600 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply status-badge bg-emerald-500/20 text-emerald-400 border border-emerald-500/30;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply status-badge bg-gray-500/20 text-gray-400 border border-gray-500/30;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply status-badge bg-red-500/20 text-red-400 border border-red-500/30;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply status-badge bg-amber-500/20 text-amber-400 border border-amber-500/30;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-dark-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-dark-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-dark-500;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse-dot {
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
.log-viewer {
|
||||
@apply font-mono text-sm leading-relaxed whitespace-pre-wrap break-all;
|
||||
}
|
||||
|
||||
.log-viewer .log-timestamp {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.log-viewer .log-error {
|
||||
@apply text-red-400;
|
||||
}
|
||||
|
||||
.log-viewer .log-success {
|
||||
@apply text-emerald-400;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
108
frontend/src/pages/AppsPage.jsx
Normal file
108
frontend/src/pages/AppsPage.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, RefreshCw, Plus } from 'lucide-react';
|
||||
import AppCard from '../components/AppCard';
|
||||
import AddAppModal from '../components/AddAppModal';
|
||||
import { getApps, getGiteaCommits } from '../utils/api';
|
||||
|
||||
export default function AppsPage({ apps, onRefresh }) {
|
||||
const [commitsMap, setCommitsMap] = useState({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Charger les commits pour chaque app
|
||||
apps.forEach((app) => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
getGiteaCommits(owner, repo)
|
||||
.then((res) => {
|
||||
setCommitsMap((prev) => ({
|
||||
...prev,
|
||||
[app.id]: res.data,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignorer les erreurs Gitea pour les apps sans dépôt
|
||||
});
|
||||
});
|
||||
}, [apps]);
|
||||
|
||||
const handleAddSuccess = () => {
|
||||
setShowAddModal(false);
|
||||
// Rafraîchir la liste après un délai pour laisser le temps au health check
|
||||
setTimeout(() => {
|
||||
if (onRefresh) onRefresh();
|
||||
}, 5000);
|
||||
// Rafraîchir encore après 15s
|
||||
setTimeout(() => {
|
||||
if (onRefresh) onRefresh();
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Box className="w-7 h-7 text-primary-400" />
|
||||
Applications
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Gérez et surveillez vos applications déployées
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onRefresh} className="btn-secondary flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Rafraîchir
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Ajouter une application
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps List */}
|
||||
{apps.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Box className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-400">
|
||||
Aucune application
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Les applications déployées apparaîtront ici
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary mt-4 inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Ajouter votre première application
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{apps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
commits={commitsMap[app.id] || []}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add App Modal */}
|
||||
<AddAppModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={handleAddSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
frontend/src/pages/DashboardPage.jsx
Normal file
190
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Container,
|
||||
Server,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { getApps, getSystemInfo } from '../utils/api';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color, subtext }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{label}</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{value}</p>
|
||||
{subtext && <p className="text-xs text-gray-500 mt-1">{subtext}</p>}
|
||||
</div>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center ${color}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage({ apps }) {
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSystemInfo()
|
||||
.then((res) => setSystemInfo(res.data))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const onlineApps = apps.filter((a) => a.status === 'online').length;
|
||||
const offlineApps = apps.filter((a) => a.status === 'offline').length;
|
||||
const errorApps = apps.filter((a) => a.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Tableau de bord</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Vue d'ensemble de l'infrastructure Manus
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Applications"
|
||||
value={apps.length}
|
||||
color="bg-primary-600"
|
||||
subtext="Applications déployées"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="En ligne"
|
||||
value={onlineApps}
|
||||
color="bg-emerald-600"
|
||||
subtext="Applications fonctionnelles"
|
||||
/>
|
||||
<StatCard
|
||||
icon={XCircle}
|
||||
label="Hors ligne"
|
||||
value={offlineApps}
|
||||
color="bg-gray-600"
|
||||
subtext="Applications arrêtées"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertCircle}
|
||||
label="Erreurs"
|
||||
value={errorApps}
|
||||
color="bg-red-600"
|
||||
subtext="Applications en erreur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Info + Apps Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* System Info */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary-400" />
|
||||
Informations système
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Serveur</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
78.138.58.109 (Recette)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Conteneurs actifs</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
{systemInfo?.runningContainers || '...'} /{' '}
|
||||
{systemInfo?.totalContainers || '...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Gitea</span>
|
||||
<a
|
||||
href="https://git.recette.santinova-soft.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
git.recette.santinova-soft.org
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Reverse Proxy</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
Traefik + Let's Encrypt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-400">Heure serveur</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
{systemInfo?.serverTime
|
||||
? new Date(systemInfo.serverTime).toLocaleString('fr-FR')
|
||||
: '...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps Quick View */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary-400" />
|
||||
État des applications
|
||||
</h3>
|
||||
{apps.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Aucune application détectée</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center justify-between py-3 px-4 rounded-lg bg-dark-700/50 border border-dark-600/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
app.status === 'online'
|
||||
? 'bg-emerald-400 animate-pulse-dot'
|
||||
: app.status === 'error'
|
||||
? 'bg-red-400'
|
||||
: 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{app.containerName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{app.health?.recette?.responseTime && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{app.health.recette.responseTime}ms
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge status={app.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/pages/DeploymentsPage.jsx
Normal file
157
frontend/src/pages/DeploymentsPage.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ScrollText,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader,
|
||||
} from 'lucide-react';
|
||||
import { getDeployments } from '../utils/api';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('fr-FR');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function DeploymentEntry({ deployment }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = {
|
||||
success: <CheckCircle2 className="w-5 h-5 text-emerald-400" />,
|
||||
failed: <XCircle className="w-5 h-5 text-red-400" />,
|
||||
running: <Loader className="w-5 h-5 text-amber-400 animate-spin" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-dark-700/50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{statusIcon[deployment.status] || statusIcon.running}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{deployment.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(deployment.timestamp)}
|
||||
</span>
|
||||
{deployment.user && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{deployment.user}
|
||||
</span>
|
||||
)}
|
||||
{deployment.duration && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Durée: {deployment.duration}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={deployment.status} />
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && deployment.logs && (
|
||||
<div className="px-4 pb-4 border-t border-dark-700">
|
||||
<div className="bg-dark-950 rounded-lg p-4 mt-3 max-h-96 overflow-auto">
|
||||
<pre className="log-viewer text-xs text-gray-400">
|
||||
{deployment.logs}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeploymentsPage() {
|
||||
const [deployments, setDeployments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchDeployments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDeployments();
|
||||
setDeployments(res.data);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement déploiements:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeployments();
|
||||
const interval = setInterval(fetchDeployments, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<ScrollText className="w-7 h-7 text-primary-400" />
|
||||
Historique des déploiements
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Consultez les logs des derniers déploiements
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchDeployments}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Deployments List */}
|
||||
{loading && deployments.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<RefreshCw className="w-8 h-8 text-gray-600 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-gray-400">Chargement...</p>
|
||||
</div>
|
||||
) : deployments.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<ScrollText className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-400">
|
||||
Aucun déploiement
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2">
|
||||
L'historique des déploiements apparaîtra ici après le premier
|
||||
redéploiement
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{deployments.map((d) => (
|
||||
<DeploymentEntry key={d.id} deployment={d} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/DockerPage.jsx
Normal file
164
frontend/src/pages/DockerPage.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Container, RefreshCw, Clock } from 'lucide-react';
|
||||
import { getContainers } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('fr-FR');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DockerPage() {
|
||||
const [containers, setContainers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchContainers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getContainers();
|
||||
setContainers(res.data);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement conteneurs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers();
|
||||
const interval = setInterval(fetchContainers, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const stateColors = {
|
||||
running: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
exited: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
created: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
restarting: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Container className="w-7 h-7 text-primary-400" />
|
||||
Conteneurs Docker
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Vue d'ensemble de tous les conteneurs sur le serveur
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchContainers}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Containers Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700">
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
Nom
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
Image
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
État
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
Statut
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
ID
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-4">
|
||||
Créé
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{loading && containers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-8 text-center">
|
||||
<RefreshCw className="w-6 h-6 text-gray-600 mx-auto animate-spin" />
|
||||
</td>
|
||||
</tr>
|
||||
) : containers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan="6"
|
||||
className="px-6 py-8 text-center text-gray-500"
|
||||
>
|
||||
Aucun conteneur trouvé
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
containers.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="hover:bg-dark-700/30 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3">
|
||||
<span className="text-sm font-medium text-gray-200">
|
||||
{c.names.join(', ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<code className="text-xs text-gray-400 bg-dark-700 px-2 py-0.5 rounded">
|
||||
{c.image}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
stateColors[c.state] || stateColors.created
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
c.state === 'running'
|
||||
? 'bg-emerald-400'
|
||||
: c.state === 'exited'
|
||||
? 'bg-red-400'
|
||||
: 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
{c.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className="text-xs text-gray-400">{c.status}</span>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<code className="text-xs text-gray-500 font-mono">
|
||||
{c.id}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(c.created)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
420
frontend/src/pages/DocumentationPage.jsx
Normal file
420
frontend/src/pages/DocumentationPage.jsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Server,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Activity,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Données de l'infrastructure
|
||||
───────────────────────────────────────────── */
|
||||
const RECETTE = {
|
||||
label: 'RECETTE',
|
||||
color: 'amber',
|
||||
ip: '78.138.58.109',
|
||||
os: 'Debian GNU/Linux 12',
|
||||
cpu: '4 vCores',
|
||||
ram: '8 Go',
|
||||
disk: '147 Go',
|
||||
infra: [
|
||||
{
|
||||
icon: GitBranch,
|
||||
color: 'purple',
|
||||
name: 'Gitea',
|
||||
url: 'git.recette.santinova-soft.org',
|
||||
desc: 'Dépôt Git — 4 repositories',
|
||||
href: 'https://git.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
color: 'blue',
|
||||
name: 'Dashboard',
|
||||
url: 'dashboard.recette.santinova-soft.org',
|
||||
desc: 'Monitoring CPU / RAM / Disque',
|
||||
href: 'https://dashboard.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail.recette.santinova-soft.org',
|
||||
desc: 'Vitrine centralisée des applications',
|
||||
href: 'https://portail.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'gray',
|
||||
name: 'Traefik',
|
||||
url: 'Reverse Proxy SSL',
|
||||
desc: "Let's Encrypt — Routage dynamique",
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
icon: '🧾',
|
||||
color: 'green',
|
||||
name: 'Démat. Facturation DSI',
|
||||
url: 'demat-facturation.recette.santinova-soft.org',
|
||||
href: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '👁️',
|
||||
color: 'indigo',
|
||||
name: 'Veille Réglementaire',
|
||||
url: 'veille.recette.santinova-soft.org',
|
||||
href: 'https://veille.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🗺️',
|
||||
color: 'cyan',
|
||||
name: 'SONUM',
|
||||
url: 'sonum.recette.santinova-soft.org',
|
||||
desc: 'Cartographie solutions numériques FEHAP',
|
||||
href: 'https://sonum.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '👤',
|
||||
color: 'pink',
|
||||
name: 'Itinova Contacts',
|
||||
url: 'contacts.recette.santinova-soft.org',
|
||||
href: 'https://contacts.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🚗',
|
||||
color: 'yellow',
|
||||
name: 'Flotte Véhicules',
|
||||
url: 'flotte.recette.santinova-soft.org',
|
||||
href: 'https://flotte.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🎧',
|
||||
color: 'orange',
|
||||
name: 'Podcasts Itinova',
|
||||
url: 'podcasts.recette.santinova-soft.org',
|
||||
href: 'https://podcasts.recette.santinova-soft.org',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PRODUCTION = {
|
||||
label: 'PRODUCTION',
|
||||
color: 'emerald',
|
||||
ip: '180.149.196.138',
|
||||
os: 'Debian GNU/Linux 12',
|
||||
cpu: '8 vCores',
|
||||
ram: '16 Go',
|
||||
disk: '246 Go',
|
||||
services: [
|
||||
{
|
||||
icon: GitBranch,
|
||||
color: 'purple',
|
||||
name: 'Gitea',
|
||||
url: 'git.santinova-soft.org',
|
||||
desc: 'Dépôt Git principal',
|
||||
href: 'https://git.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
color: 'blue',
|
||||
name: 'Dashboard',
|
||||
url: 'dashboard.santinova-soft.org',
|
||||
desc: 'Monitoring temps réel',
|
||||
href: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail.santinova-soft.org',
|
||||
desc: 'Vitrine applicative production',
|
||||
href: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
color: 'gray',
|
||||
name: 'Traefik',
|
||||
url: 'Reverse Proxy SSL',
|
||||
desc: "Let's Encrypt — Routage dynamique",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CICD_STEPS = [
|
||||
{ icon: GitBranch, label: 'Push code', desc: 'Développeur pousse sur Gitea' },
|
||||
{ icon: Zap, label: 'Webhook', desc: 'Gitea déclenche le webhook' },
|
||||
{ icon: Package, label: 'docker compose build', desc: 'Build de la nouvelle image' },
|
||||
{ icon: Server, label: 'docker compose up -d', desc: 'Redémarrage du conteneur' },
|
||||
{ icon: CheckCircle, label: 'Health check', desc: 'Vérification de disponibilité' },
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Helpers de couleur
|
||||
───────────────────────────────────────────── */
|
||||
const colorMap = {
|
||||
purple: 'border-purple-500/40 bg-purple-500/10 text-purple-300',
|
||||
blue: 'border-blue-500/40 bg-blue-500/10 text-blue-300',
|
||||
teal: 'border-teal-500/40 bg-teal-500/10 text-teal-300',
|
||||
gray: 'border-gray-500/40 bg-gray-500/10 text-gray-300',
|
||||
green: 'border-green-500/40 bg-green-500/10 text-green-300',
|
||||
indigo: 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300',
|
||||
cyan: 'border-cyan-500/40 bg-cyan-500/10 text-cyan-300',
|
||||
pink: 'border-pink-500/40 bg-pink-500/10 text-pink-300',
|
||||
yellow: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-300',
|
||||
orange: 'border-orange-500/40 bg-orange-500/10 text-orange-300',
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Composants
|
||||
───────────────────────────────────────────── */
|
||||
function ServerBadge({ env }) {
|
||||
const isAmber = env.color === 'amber';
|
||||
const borderColor = isAmber ? 'border-amber-500/30' : 'border-emerald-500/30';
|
||||
const bgColor = isAmber ? 'bg-amber-500/5' : 'bg-emerald-500/5';
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-4 p-3 rounded-xl border ${borderColor} ${bgColor} mb-5`}>
|
||||
{[
|
||||
{ icon: Server, label: env.ip },
|
||||
{ icon: Cpu, label: env.cpu },
|
||||
{ icon: Activity, label: env.ram + ' RAM' },
|
||||
{ icon: HardDrive, label: env.disk + ' Disque' },
|
||||
{ icon: Network, label: env.os },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<div key={label} className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ item, withDb = false }) {
|
||||
const cls = colorMap[item.color] || colorMap.gray;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div className={`flex items-start gap-3 p-3 rounded-xl border ${cls} transition-all hover:scale-[1.01]`}>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{typeof Icon === 'string' ? (
|
||||
<span className="text-xl">{Icon}</span>
|
||||
) : (
|
||||
<Icon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-white">{item.name}</span>
|
||||
{item.href && (
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 truncate">{item.url}</p>
|
||||
{item.desc && <p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>}
|
||||
</div>
|
||||
{withDb && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600 flex-shrink-0">
|
||||
<Database className="w-3 h-3" />
|
||||
<span>MySQL</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children, color = 'amber' }) {
|
||||
const cls = color === 'amber' ? 'text-amber-400' : 'text-emerald-400';
|
||||
return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Page principale
|
||||
───────────────────────────────────────────── */
|
||||
export default function DocumentationPage() {
|
||||
const [schemaOpen, setSchemaOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* ── En-tête ── */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<BookOpen className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Documentation Infrastructure</h1>
|
||||
<p className="text-sm text-gray-400">Architecture de déploiement Santinova — Recette & Production</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Clock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Schéma d'architecture ── */}
|
||||
<div className="rounded-2xl border border-dark-700 bg-dark-900 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSchemaOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-6 py-4 hover:bg-dark-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Network className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-white">Schéma d'Architecture</span>
|
||||
</div>
|
||||
{schemaOpen ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{schemaOpen && (
|
||||
<div className="px-6 pb-6">
|
||||
<img
|
||||
src="/infra_schema_v2.png"
|
||||
alt="Schéma d'architecture infrastructure Santinova"
|
||||
className="w-full rounded-xl border border-dark-700 shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Grille Recette / CI-CD / Production ── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">
|
||||
|
||||
{/* ── RECETTE ── */}
|
||||
<div className="rounded-2xl border border-amber-500/30 bg-dark-900 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-lg shadow-amber-400/50" />
|
||||
<h2 className="text-xl font-bold text-amber-400 tracking-wide">RECETTE</h2>
|
||||
<span className="ml-auto text-xs text-gray-500 font-mono">78.138.58.109</span>
|
||||
</div>
|
||||
<ServerBadge env={RECETTE} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Infra de base */}
|
||||
<div>
|
||||
<SectionTitle color="amber">Infrastructure de base</SectionTitle>
|
||||
<div className="space-y-2">
|
||||
{RECETTE.infra.map((item) => (
|
||||
<ServiceCard key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Applications */}
|
||||
<div>
|
||||
<SectionTitle color="amber">Applications déployées</SectionTitle>
|
||||
<div className="space-y-2">
|
||||
{RECETTE.apps.map((item) => (
|
||||
<ServiceCard key={item.name} item={item} withDb />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CI/CD Pipeline ── */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-4 xl:px-2 py-6">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-blue-400 mb-2">CI/CD</span>
|
||||
{CICD_STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<React.Fragment key={step.label}>
|
||||
<div className="flex flex-col items-center gap-1 group">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center group-hover:bg-blue-600/30 transition-colors">
|
||||
<Icon className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 text-center max-w-[80px] leading-tight">{step.label}</span>
|
||||
</div>
|
||||
{i < CICD_STEPS.length - 1 && (
|
||||
<div className="w-px h-4 bg-gradient-to-b from-blue-500/50 to-blue-500/10" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-xl bg-blue-600/10 border border-blue-500/20">
|
||||
<ArrowRight className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs text-blue-300 font-semibold whitespace-nowrap">Promotion → PROD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── PRODUCTION ── */}
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-dark-900 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400 shadow-lg shadow-emerald-400/50" />
|
||||
<h2 className="text-xl font-bold text-emerald-400 tracking-wide">PRODUCTION</h2>
|
||||
<span className="ml-auto text-xs text-gray-500 font-mono">180.149.196.138</span>
|
||||
</div>
|
||||
<ServerBadge env={PRODUCTION} />
|
||||
|
||||
<SectionTitle color="emerald">Services actifs</SectionTitle>
|
||||
<div className="space-y-2 mb-4">
|
||||
{PRODUCTION.services.map((item) => (
|
||||
<ServiceCard key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Carte "Applications à venir" */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl border border-dashed border-gray-600/40 bg-gray-500/5">
|
||||
<Package className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-400">Applications à venir</p>
|
||||
<p className="text-xs text-gray-600">Déploiement progressif via CI/CD</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge metrics-collector */}
|
||||
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/20">
|
||||
<Activity className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-xs text-emerald-400">metrics-collector · systemd · actif</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Légende technologique ── */}
|
||||
<div className="rounded-2xl border border-dark-700 bg-dark-900 p-6">
|
||||
<h3 className="text-sm font-bold text-white mb-4">Stack Technologique</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ emoji: '🐳', label: 'Docker', desc: 'Conteneurisation' },
|
||||
{ emoji: '🔒', label: 'HTTPS', desc: "Let's Encrypt SSL" },
|
||||
{ emoji: '🗄️', label: 'MySQL 8.0', desc: 'Base de données' },
|
||||
{ emoji: '🔀', label: 'Traefik', desc: 'Reverse Proxy' },
|
||||
].map((t) => (
|
||||
<div
|
||||
key={t.label}
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-dark-800 border border-dark-700"
|
||||
>
|
||||
<span className="text-2xl">{t.emoji}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{t.label}</p>
|
||||
<p className="text-xs text-gray-500">{t.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
frontend/src/pages/GiteaPage.jsx
Normal file
227
frontend/src/pages/GiteaPage.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
Unlock,
|
||||
GitCommit,
|
||||
Clock,
|
||||
User,
|
||||
Code,
|
||||
} from 'lucide-react';
|
||||
import { getGiteaRepos, getGiteaCommits } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('fr-FR');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(kb) {
|
||||
if (!kb) return 'N/A';
|
||||
if (kb < 1024) return `${kb} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function GiteaPage() {
|
||||
const [repos, setRepos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRepo, setSelectedRepo] = useState(null);
|
||||
const [commits, setCommits] = useState([]);
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getGiteaRepos();
|
||||
setRepos(res.data);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement repos:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCommits = async (fullName) => {
|
||||
setLoadingCommits(true);
|
||||
setSelectedRepo(fullName);
|
||||
try {
|
||||
const [owner, repo] = fullName.split('/');
|
||||
const res = await getGiteaCommits(owner, repo);
|
||||
setCommits(res.data);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement commits:', err);
|
||||
setCommits([]);
|
||||
} finally {
|
||||
setLoadingCommits(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<GitBranch className="w-7 h-7 text-primary-400" />
|
||||
Dépôts Gitea
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Explorez les dépôts sur{' '}
|
||||
<a
|
||||
href="https://git.recette.santinova-soft.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
git.recette.santinova-soft.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRepos}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos List */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||
Dépôts ({repos.length})
|
||||
</h3>
|
||||
{loading ? (
|
||||
<div className="card p-8 text-center">
|
||||
<RefreshCw className="w-6 h-6 text-gray-600 mx-auto animate-spin" />
|
||||
</div>
|
||||
) : repos.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<GitBranch className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Aucun dépôt trouvé</p>
|
||||
</div>
|
||||
) : (
|
||||
repos.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className={`card p-4 cursor-pointer transition-all ${
|
||||
selectedRepo === repo.fullName
|
||||
? 'border-primary-500/50 bg-primary-500/5'
|
||||
: 'hover:border-dark-600'
|
||||
}`}
|
||||
onClick={() => fetchCommits(repo.fullName)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4 text-primary-400" />
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
{repo.name}
|
||||
</h4>
|
||||
{repo.private ? (
|
||||
<Lock className="w-3 h-3 text-gray-500" />
|
||||
) : (
|
||||
<Unlock className="w-3 h-3 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{repo.description || 'Pas de description'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
{repo.language && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatSize(repo.size)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Mis à jour: {formatDate(repo.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={repo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-primary-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commits */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||
{selectedRepo
|
||||
? `Commits — ${selectedRepo}`
|
||||
: 'Sélectionnez un dépôt'}
|
||||
</h3>
|
||||
{!selectedRepo ? (
|
||||
<div className="card p-8 text-center">
|
||||
<GitCommit className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400 text-sm">
|
||||
Cliquez sur un dépôt pour voir ses commits
|
||||
</p>
|
||||
</div>
|
||||
) : loadingCommits ? (
|
||||
<div className="card p-8 text-center">
|
||||
<RefreshCw className="w-6 h-6 text-gray-600 mx-auto animate-spin" />
|
||||
</div>
|
||||
) : commits.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<GitCommit className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Aucun commit trouvé</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{commits.map((commit, idx) => (
|
||||
<div key={commit.sha || idx} className="card p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<GitCommit className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200 truncate">
|
||||
{commit.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<code className="text-xs bg-dark-700 text-primary-400 px-1.5 py-0.5 rounded font-mono">
|
||||
{commit.shortSha}
|
||||
</code>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{commit.author}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(commit.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
frontend/src/pages/InventairePage.jsx
Normal file
458
frontend/src/pages/InventairePage.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react';
|
||||
import api from '../utils/api';
|
||||
|
||||
const APPS_CONFIG = [
|
||||
{
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
repoName: 'itinova-contacts',
|
||||
urlRecette: 'https://contacts.recette.santinova-soft.org',
|
||||
urlProd: 'https://contacts.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
repoName: 'itinova-podcasts',
|
||||
urlRecette: 'https://podcasts.recette.santinova-soft.org',
|
||||
urlProd: 'https://podcasts.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'veille-reglementaire',
|
||||
name: 'Veille Réglementaire',
|
||||
repoName: 'veille-reglementaire',
|
||||
urlRecette: 'https://veille.recette.santinova-soft.org',
|
||||
urlProd: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Itinova Gestion de Flotte',
|
||||
repoName: 'itinova-vehicle-exchange',
|
||||
urlRecette: 'https://flotte.recette.santinova-soft.org',
|
||||
urlProd: 'https://flotte.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'sonum',
|
||||
name: 'SONUM',
|
||||
repoName: 'sonum',
|
||||
urlRecette: 'https://sonum.recette.santinova-soft.org',
|
||||
urlProd: 'https://sonum.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'demat-facturation-dsi',
|
||||
name: 'Démat. Facturation DSI',
|
||||
repoName: 'demat-facturation-dsi',
|
||||
urlRecette: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
urlProd: 'https://demat-facturation.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'facturation-santinova',
|
||||
name: 'Facturation Santinova',
|
||||
repoName: 'facturation-santinova',
|
||||
urlRecette: 'https://facturation.recette.santinova-soft.org',
|
||||
urlProd: null,
|
||||
},
|
||||
{
|
||||
id: 'formation-manager-itinova',
|
||||
name: 'Formation Manager',
|
||||
repoName: 'formation-manager-itinova',
|
||||
urlRecette: 'https://formation.recette.santinova-soft.org',
|
||||
urlProd: 'https://formations.itinova.org',
|
||||
},
|
||||
{
|
||||
id: 'pilotage-masse-salariale',
|
||||
name: 'Pilotage Masse Salariale',
|
||||
repoName: 'pilotage-masse-salariale',
|
||||
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
urlProd: null,
|
||||
},
|
||||
{
|
||||
id: 'itinova-budget-si',
|
||||
name: 'Gestion Budget Informatique',
|
||||
repoName: 'itinova-budget-si',
|
||||
urlRecette: 'https://budget-si.recette.santinova-soft.org',
|
||||
urlProd: null,
|
||||
},
|
||||
{
|
||||
id: 'falc-generator',
|
||||
name: 'FALC Generator',
|
||||
repoName: 'falc-generator',
|
||||
urlRecette: 'https://falc.recette.santinova-soft.org',
|
||||
urlProd: 'https://falc.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'portail-santinova',
|
||||
name: 'Portail Applicatif',
|
||||
repoName: 'portail-santinova',
|
||||
urlRecette: 'https://portail.recette.santinova-soft.org',
|
||||
urlProd: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard Recette',
|
||||
repoName: 'manus-dashboard',
|
||||
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
||||
urlProd: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
function SyncBadge({ versionRecette, versionProd }) {
|
||||
// Extraire le SHA (7 premiers caractères avant l'espace)
|
||||
const shaRec = versionRecette ? versionRecette.split(' ')[0] : null;
|
||||
const shaProd = versionProd ? versionProd.split(' ')[0] : null;
|
||||
// Extraire la date entre parenthèses pour comparer
|
||||
const dateRec = versionRecette ? versionRecette.match(/\((.+?)\)/)?.[1] : null;
|
||||
const dateProd = versionProd ? versionProd.match(/\((.+?)\)/)?.[1] : null;
|
||||
|
||||
if (!shaRec && !shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<Minus className="w-3 h-3" />
|
||||
N/A
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!shaRec) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title="Uniquement en production">
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Prod en avance
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
Recette en avance
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (shaRec === shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-900/40 text-emerald-300 border border-emerald-700/40" title="Versions identiques">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Synchronisé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Comparer les dates pour savoir qui est en avance
|
||||
// Format fr-FR : dd/mm/yyyy
|
||||
const parseDate = (s) => {
|
||||
if (!s) return null;
|
||||
const parts = s.split('/');
|
||||
if (parts.length === 3) return new Date(`${parts[2]}-${parts[1]}-${parts[0]}`);
|
||||
return new Date(s);
|
||||
};
|
||||
const dRec = parseDate(dateRec);
|
||||
const dProd = parseDate(dateProd);
|
||||
|
||||
if (dRec && dProd) {
|
||||
if (dRec > dProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
Recette en avance
|
||||
</span>
|
||||
);
|
||||
} else if (dProd > dRec) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Prod en avance
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
// SHA différents mais dates non comparables
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Divergent
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBadge({ version, loading }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||
<Tag className="w-3 h-3" />
|
||||
Chargement...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!version) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
N/A
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-blue-900/40 text-blue-300 border border-blue-700/40">
|
||||
<Tag className="w-3 h-3" />
|
||||
{version}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoBadge({ present, url, label }) {
|
||||
if (!present) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<span className="text-gray-500 text-xs">Absent</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-400 hover:text-primary-300 hover:underline flex items-center gap-1 truncate max-w-[180px]"
|
||||
title={url}
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InventairePage() {
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
const fetchInventory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/inventory');
|
||||
setInventory(res.data);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement inventaire:', err);
|
||||
// Fallback : construire l'inventaire depuis la config statique
|
||||
const fallback = APPS_CONFIG.map((app) => ({
|
||||
...app,
|
||||
repoRecette: { present: false, url: null, version: null },
|
||||
repoProd: { present: false, url: null, version: null },
|
||||
}));
|
||||
setInventory(fallback);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-refresh toutes les 5 minutes
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
const interval = setInterval(fetchInventory, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Table className="w-7 h-7 text-emerald-400" />
|
||||
Inventaire des Applications
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
État des dépôts Git et versions déployées par environnement
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Mis à jour : {lastUpdate.toLocaleTimeString('fr-FR')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchInventory}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{!loading && inventory.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="card p-4 text-center border border-dark-700">
|
||||
<div className="text-2xl font-bold text-white">{inventory.length}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Applications totales</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center border border-orange-700/30">
|
||||
<div className="text-2xl font-bold text-orange-400">
|
||||
{inventory.filter((a) => a.repoRecette?.present).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Dépôts Recette</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center border border-green-700/30">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{inventory.filter((a) => a.repoProd?.present).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Dépôts Production</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center border border-emerald-700/30">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{inventory.filter((a) => {
|
||||
const shaRec = a.repoRecette?.version?.split(' ')[0];
|
||||
const shaProd = a.repoProd?.version?.split(' ')[0];
|
||||
return shaRec && shaProd && shaRec === shaProd;
|
||||
}).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Synchronisés</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center border border-blue-700/30">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Déployés sur les 2 envs</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-6 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
Dépôt présent
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
Dépôt absent
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tag className="w-4 h-4 text-blue-400" />
|
||||
Version (dernier commit)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
|
||||
Application
|
||||
</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-purple-400 uppercase tracking-wider">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
Synchronisation
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider" colSpan={2}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
Recette
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-green-400 uppercase tracking-wider" colSpan={2}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
Production
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b border-dark-700 bg-dark-800/50">
|
||||
<th className="px-4 py-2"></th>
|
||||
<th className="px-4 py-2"></th>
|
||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
|
||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
|
||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
|
||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{loading && inventory.length === 0
|
||||
? APPS_CONFIG.map((app) => (
|
||||
<tr key={app.id} className="hover:bg-dark-800/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-white">{app.name}</span>
|
||||
</td>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<td key={i} className="px-3 py-3 text-center">
|
||||
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: inventory.map((app) => {
|
||||
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
|
||||
return (
|
||||
<tr key={app.id} className={`hover:bg-dark-800/30 transition-colors${isInfra ? ' bg-amber-950/10 border-l-2 border-amber-500/40' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${isInfra ? 'text-amber-300' : 'text-white'}`}>{app.name}</span>
|
||||
{isInfra && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 border border-amber-500/25 font-semibold">Infra</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Synchronisation */}
|
||||
<td className="px-3 py-3 text-center">
|
||||
<SyncBadge
|
||||
versionRecette={app.repoRecette?.version}
|
||||
versionProd={app.repoProd?.version}
|
||||
/>
|
||||
</td>
|
||||
{/* Recette - Dépôt */}
|
||||
<td className="px-3 py-3">
|
||||
<RepoBadge
|
||||
present={app.repoRecette?.present}
|
||||
url={app.repoRecette?.url}
|
||||
label={`git.recette/…/${app.repoName}`}
|
||||
/>
|
||||
</td>
|
||||
{/* Recette - Version */}
|
||||
<td className="px-3 py-3 text-center">
|
||||
<VersionBadge
|
||||
version={app.repoRecette?.version}
|
||||
loading={loading}
|
||||
/>
|
||||
</td>
|
||||
{/* Prod - Dépôt */}
|
||||
<td className="px-3 py-3">
|
||||
<RepoBadge
|
||||
present={app.repoProd?.present}
|
||||
url={app.repoProd?.url}
|
||||
label={`git.santinova/…/${app.repoName}`}
|
||||
/>
|
||||
</td>
|
||||
{/* Prod - Version */}
|
||||
<td className="px-3 py-3 text-center">
|
||||
<VersionBadge
|
||||
version={app.repoProd?.version}
|
||||
loading={loading}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
frontend/src/pages/LoginPage.jsx
Normal file
138
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, User, AlertCircle, Server } from 'lucide-react';
|
||||
import { login } from '../utils/api';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await login(username, password);
|
||||
localStorage.setItem('dashboard_token', response.data.token);
|
||||
onLogin(response.data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.response?.data?.error || 'Erreur de connexion. Veuillez réessayer.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
||||
<Server className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard Recette</h1>
|
||||
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Entrez votre identifiant"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Entrez votre mot de passe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Connexion...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4" />
|
||||
Se connecter
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-sm mt-6">
|
||||
Santinova Soft — Serveur de recette
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
frontend/src/pages/MonitoringPage.jsx
Normal file
251
frontend/src/pages/MonitoringPage.jsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Network,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Server,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { getServerMetrics } from '../utils/api';
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'Ko', 'Mo', 'Go', 'To'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return 'N/A';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}j ${hours}h ${mins}m`;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function GaugeBar({ value, max = 100, color = 'primary', label, sublabel }) {
|
||||
const pct = Math.min(100, Math.round((value / max) * 100));
|
||||
const colorMap = {
|
||||
primary: { bar: 'bg-primary-500', text: 'text-primary-400', border: 'border-primary-500/30' },
|
||||
emerald: { bar: 'bg-emerald-500', text: 'text-emerald-400', border: 'border-emerald-500/30' },
|
||||
amber: { bar: 'bg-amber-500', text: 'text-amber-400', border: 'border-amber-500/30' },
|
||||
red: { bar: 'bg-red-500', text: 'text-red-400', border: 'border-red-500/30' },
|
||||
};
|
||||
const dangerColor = pct > 85 ? 'red' : pct > 65 ? 'amber' : color;
|
||||
const c = colorMap[dangerColor] || colorMap.primary;
|
||||
|
||||
return (
|
||||
<div className={`card p-5 border ${c.border}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-300">{label}</span>
|
||||
<span className={`text-2xl font-bold ${c.text}`}>{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-dark-700 rounded-full h-3 mb-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-700 ${c.bar}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{sublabel}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, sub, color = 'primary' }) {
|
||||
const colorMap = {
|
||||
primary: 'text-primary-400 bg-primary-500/10 border-primary-500/20',
|
||||
emerald: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
amber: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
blue: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
};
|
||||
return (
|
||||
<div className={`card p-5 border ${colorMap[color]}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-xl border ${colorMap[color]}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className="text-xl font-bold text-white truncate">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-500 mt-1">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
try {
|
||||
const res = await getServerMetrics();
|
||||
setMetrics(res.data);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err) {
|
||||
console.error('Erreur métriques:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchMetrics, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchMetrics]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Activity className="w-7 h-7 text-primary-400" />
|
||||
Monitoring Serveur
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Consommation des ressources en temps réel
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Mis à jour : {lastUpdate.toLocaleTimeString('fr-FR')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={`btn-secondary text-sm flex items-center gap-2 ${autoRefresh ? 'text-emerald-400' : ''}`}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
{autoRefresh ? 'Auto (10s)' : 'Manuel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !metrics ? (
|
||||
<div className="card p-12 text-center">
|
||||
<RefreshCw className="w-8 h-8 text-primary-400 animate-spin mx-auto mb-3" />
|
||||
<p className="text-gray-400">Chargement des métriques...</p>
|
||||
</div>
|
||||
) : metrics?.error ? (
|
||||
<div className="card p-8 text-center border border-red-500/20">
|
||||
<p className="text-red-400">Erreur : {metrics.error}</p>
|
||||
</div>
|
||||
) : metrics ? (
|
||||
<>
|
||||
{/* Jauges principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<GaugeBar
|
||||
value={metrics.cpu?.usage || 0}
|
||||
label="Processeur (CPU)"
|
||||
sublabel={`Load avg: ${metrics.load?.load1?.toFixed(2)} / ${metrics.load?.load5?.toFixed(2)} / ${metrics.load?.load15?.toFixed(2)}`}
|
||||
color="primary"
|
||||
/>
|
||||
<GaugeBar
|
||||
value={metrics.memory?.usagePercent || 0}
|
||||
label="Mémoire RAM"
|
||||
sublabel={`${formatBytes(metrics.memory?.used)} utilisés sur ${formatBytes(metrics.memory?.total)} — ${formatBytes(metrics.memory?.available)} disponibles`}
|
||||
color="emerald"
|
||||
/>
|
||||
<GaugeBar
|
||||
value={metrics.disk?.usagePercent || 0}
|
||||
label="Espace Disque"
|
||||
sublabel={`${formatBytes(metrics.disk?.used)} utilisés sur ${formatBytes(metrics.disk?.total)} — ${formatBytes(metrics.disk?.free)} libres`}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Métriques détaillées */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
icon={Server}
|
||||
label="Uptime serveur"
|
||||
value={formatUptime(metrics.uptime)}
|
||||
sub="Depuis le dernier redémarrage"
|
||||
color="primary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={TrendingUp}
|
||||
label="Charge système (1 min)"
|
||||
value={metrics.load?.load1?.toFixed(2) || 'N/A'}
|
||||
sub={`5 min: ${metrics.load?.load5?.toFixed(2)} — 15 min: ${metrics.load?.load15?.toFixed(2)}`}
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Network}
|
||||
label="Réseau — Reçu"
|
||||
value={formatBytes(metrics.network?.rx)}
|
||||
sub="Total depuis le démarrage"
|
||||
color="emerald"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Network}
|
||||
label="Réseau — Envoyé"
|
||||
value={formatBytes(metrics.network?.tx)}
|
||||
sub="Total depuis le démarrage"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tableau récapitulatif */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-dark-700">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5 text-primary-400" />
|
||||
Récapitulatif des ressources
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-700">
|
||||
{[
|
||||
{ label: 'CPU — Utilisation', value: `${metrics.cpu?.usage?.toFixed(1) || 0}%`, status: metrics.cpu?.usage > 85 ? 'critical' : metrics.cpu?.usage > 65 ? 'warning' : 'ok' },
|
||||
{ label: 'RAM — Utilisée', value: `${formatBytes(metrics.memory?.used)} / ${formatBytes(metrics.memory?.total)}`, status: metrics.memory?.usagePercent > 85 ? 'critical' : metrics.memory?.usagePercent > 65 ? 'warning' : 'ok' },
|
||||
{ label: 'RAM — Disponible', value: formatBytes(metrics.memory?.available), status: 'ok' },
|
||||
{ label: 'Disque — Utilisé', value: `${formatBytes(metrics.disk?.used)} / ${formatBytes(metrics.disk?.total)}`, status: metrics.disk?.usagePercent > 85 ? 'critical' : metrics.disk?.usagePercent > 65 ? 'warning' : 'ok' },
|
||||
{ label: 'Disque — Libre', value: formatBytes(metrics.disk?.free), status: 'ok' },
|
||||
{ label: 'Réseau — Trafic entrant', value: formatBytes(metrics.network?.rx), status: 'ok' },
|
||||
{ label: 'Réseau — Trafic sortant', value: formatBytes(metrics.network?.tx), status: 'ok' },
|
||||
{ label: 'Uptime', value: formatUptime(metrics.uptime), status: 'ok' },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="px-6 py-3 flex items-center justify-between hover:bg-dark-800/50 transition-colors">
|
||||
<span className="text-sm text-gray-400">{row.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-white">{row.value}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
row.status === 'critical' ? 'bg-red-400' :
|
||||
row.status === 'warning' ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
449
frontend/src/pages/TerminalPage.jsx
Normal file
449
frontend/src/pages/TerminalPage.jsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Terminal, Wifi, WifiOff, X, Plus, ChevronDown } from 'lucide-react';
|
||||
|
||||
// Serveurs SSH prédéfinis
|
||||
const PRESET_SERVERS = [
|
||||
{
|
||||
id: 'recette',
|
||||
label: 'Recette (78.138.58.109)',
|
||||
host: '78.138.58.109',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
label: 'Production (180.149.196.138)',
|
||||
host: '180.149.196.138',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
},
|
||||
];
|
||||
|
||||
export default function TerminalPage() {
|
||||
const terminalRef = useRef(null);
|
||||
const xtermRef = useRef(null);
|
||||
const fitAddonRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [xtermLoaded, setXtermLoaded] = useState(false);
|
||||
|
||||
// Formulaire de connexion
|
||||
const [form, setForm] = useState({
|
||||
preset: 'recette',
|
||||
host: '78.138.58.109',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
password: '',
|
||||
});
|
||||
const [showForm, setShowForm] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Charger xterm.js dynamiquement depuis CDN
|
||||
useEffect(() => {
|
||||
const loadXterm = async () => {
|
||||
if (window.Terminal) {
|
||||
setXtermLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Charger le CSS xterm
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Charger xterm.js
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js');
|
||||
|
||||
setXtermLoaded(true);
|
||||
};
|
||||
|
||||
loadXterm();
|
||||
}, []);
|
||||
|
||||
const loadScript = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
// Initialiser xterm quand il est chargé et que le terminal est visible
|
||||
useEffect(() => {
|
||||
if (!xtermLoaded || !terminalRef.current || xtermRef.current) return;
|
||||
|
||||
const term = new window.Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", monospace',
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
black: '#484f58',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ffa198',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Recette Santinova ║\x1b[0m');
|
||||
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
|
||||
term.writeln('');
|
||||
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');
|
||||
term.writeln('');
|
||||
|
||||
// Gérer la saisie utilisateur
|
||||
term.onData((data) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Gérer le redimensionnement
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
rows: term.rows,
|
||||
cols: term.cols,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (terminalRef.current) {
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [xtermLoaded]);
|
||||
|
||||
const handlePresetChange = (presetId) => {
|
||||
const preset = PRESET_SERVERS.find(s => s.id === presetId);
|
||||
if (preset) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
preset: presetId,
|
||||
host: preset.host,
|
||||
port: preset.port,
|
||||
username: preset.username,
|
||||
}));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, preset: 'custom' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!form.host || !form.username || !form.password) {
|
||||
setError('Hôte, utilisateur et mot de passe sont requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setConnecting(true);
|
||||
setShowForm(false);
|
||||
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws-ssh?token=${token}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connect',
|
||||
host: form.host,
|
||||
port: parseInt(form.port),
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'status':
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.writeln(`\x1b[33m${msg.message}\x1b[0m`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'connected':
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.writeln(`\x1b[32m✓ ${msg.message}\x1b[0m`);
|
||||
xtermRef.current.writeln('');
|
||||
xtermRef.current.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'output':
|
||||
if (xtermRef.current) {
|
||||
const data = atob(msg.data);
|
||||
xtermRef.current.write(data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.writeln('');
|
||||
xtermRef.current.writeln(`\x1b[33m⚠ ${msg.message}\x1b[0m`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
setError(msg.message);
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.writeln(`\x1b[31m✗ Erreur: ${msg.message}\x1b[0m`);
|
||||
}
|
||||
setShowForm(true);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur parsing message SSH:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
setError('Erreur de connexion WebSocket');
|
||||
setShowForm(true);
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const handleDisconnect = () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'disconnect' }));
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setConnected(false);
|
||||
setConnecting(false);
|
||||
setShowForm(true);
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.writeln('');
|
||||
xtermRef.current.writeln('\x1b[90mDéconnecté. Configurez une nouvelle connexion.\x1b[0m');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Terminal className="w-7 h-7 text-emerald-400" />
|
||||
Terminal SSH
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Connexion SSH sécurisée aux serveurs de l'infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2 text-emerald-400 text-sm">
|
||||
<Wifi className="w-4 h-4" />
|
||||
Connecté à {form.host}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-600/20 text-red-400 border border-red-500/30 rounded-lg text-sm hover:bg-red-600/30 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Déconnecter
|
||||
</button>
|
||||
</>
|
||||
) : connecting ? (
|
||||
<span className="flex items-center gap-2 text-yellow-400 text-sm">
|
||||
<div className="w-4 h-4 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
|
||||
Connexion en cours...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<WifiOff className="w-4 h-4" />
|
||||
Non connecté
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de connexion */}
|
||||
{showForm && (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl p-6">
|
||||
<h3 className="text-white font-semibold mb-4 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-primary-400" />
|
||||
Nouvelle connexion SSH
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Serveur prédéfini */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm text-gray-400 mb-1">Serveur prédéfini</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={form.preset}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm appearance-none focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
{PRESET_SERVERS.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
<option value="custom">Serveur personnalisé...</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-3 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hôte */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Hôte / IP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value, preset: 'custom' }))}
|
||||
placeholder="192.168.1.1"
|
||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Port SSH</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, port: e.target.value }))}
|
||||
placeholder="22"
|
||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Utilisateur */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Utilisateur</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="root"
|
||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mot de passe */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
|
||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal xterm.js */}
|
||||
<div className="bg-dark-900 border border-dark-600 rounded-xl overflow-hidden">
|
||||
{/* Barre de titre style terminal */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-dark-800 border-b border-dark-600">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/70" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/70" />
|
||||
<span className="ml-2 text-xs text-gray-500 font-mono">
|
||||
{connected ? `${form.username}@${form.host}` : 'terminal — non connecté'}
|
||||
</span>
|
||||
{connected && !showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="ml-auto text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showForm ? 'Masquer' : 'Nouvelle connexion'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zone du terminal */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{ height: '500px', padding: '8px', backgroundColor: '#0d1117' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!xtermLoaded && (
|
||||
<div className="text-center text-gray-500 text-sm py-4">
|
||||
Chargement du terminal...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/utils/api.js
Normal file
73
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Intercepteur pour gérer les erreurs d'auth
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
// Auth
|
||||
export const login = (username, password) =>
|
||||
api.post('/auth/login', { username, password });
|
||||
|
||||
export const logout = () => api.post('/auth/logout');
|
||||
|
||||
export const getMe = () => api.get('/auth/me');
|
||||
|
||||
// Apps
|
||||
export const getApps = () => api.get('/apps');
|
||||
export const getApp = (id) => api.get(`/apps/${id}`);
|
||||
export const checkApp = (id) => api.post(`/apps/${id}/check`);
|
||||
export const deployApp = (id) => api.post(`/apps/${id}/deploy`);
|
||||
export const getAppLogs = (id, tail = 100) =>
|
||||
api.get(`/apps/${id}/logs`, { params: { tail } });
|
||||
|
||||
// App creation
|
||||
export const getAvailableStacks = () => api.get('/apps-config/stacks');
|
||||
export const createApp = (formData) =>
|
||||
api.post('/apps-config/create', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 600000, // 10 minutes timeout
|
||||
});
|
||||
|
||||
// Deployments
|
||||
export const getDeployments = (appId) =>
|
||||
api.get('/deployments', { params: { appId } });
|
||||
|
||||
// Gitea
|
||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||
export const getGiteaCommits = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||
|
||||
// Docker
|
||||
export const getContainers = () => api.get('/docker/containers');
|
||||
|
||||
// System
|
||||
export const getSystemInfo = () => api.get('/system/info');
|
||||
export const getServerMetrics = () => api.get('/system/metrics');
|
||||
|
||||
// Container control
|
||||
export const startApp = (id) => api.post(`/apps/${id}/start`);
|
||||
export const stopApp = (id) => api.post(`/apps/${id}/stop`);
|
||||
export const restartApp = (id) => api.post(`/apps/${id}/restart`);
|
||||
37
frontend/tailwind.config.js
Normal file
37
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
dark: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
|
||||
# Image locale de CI : Node 22, Bash, Git, GNU tar et le pnpm exact du workspace Contacts.
|
||||
# Les jobs n'ont donc plus à télécharger ou installer ces outils à chaque exécution.
|
||||
FROM node:22-alpine
|
||||
|
||||
# actions/cache s’appuie sur GNU tar pour archiver le store pnpm.
|
||||
# actions/cache requiert GNU tar et l’invoque via /bin/tar.
|
||||
RUN apk add --no-cache bash git tar \
|
||||
&& tar --version | grep -q 'GNU tar' \
|
||||
&& corepack enable \
|
||||
|
||||
@@ -4,14 +4,14 @@ Le script `healthcheck-apps.sh` est exécuté par le timer systemd toutes les ci
|
||||
|
||||
Le dernier rapport est écrit dans `/var/lib/manus-apps-health/latest.txt`. Les anomalies sont aussi consignées dans le journal système avec le tag `manus-apps-health`.
|
||||
|
||||
Les pushes Gitea du dépôt `manus-dashboard` ne recréent jamais le conteneur depuis lui-même. Le webhook dépose une demande atomique, surveillée par `manus-dashboard-self-deploy.path`, puis le service hôte exécute le redéploiement. Cette séparation évite l’arrêt du client Docker avant le démarrage du conteneur de remplacement.
|
||||
Le runner de recette est installé par `install-gitea-act-runner.sh`. Il construit une image locale Node 22 avec Bash, Git, GNU tar et pnpm 10.4.1 préactivé, puis s’enregistre auprès de Gitea avec un jeton éphémère. Les workflows s’exécutent ensuite dans cette image isolée sur le réseau Docker `web`. GNU tar est requis par `actions/cache` pour créer et restaurer les archives de dépendances.
|
||||
|
||||
Le cache Gitea Actions est actif sur le port hôte `18088` et stocke ses archives sous `/opt/manus-deploy/gitea-runner/cache`. Les workflows doivent utiliser une clé dérivée de leur fichier de verrouillage, par exemple `pnpm-lock.yaml`, afin de ne jamais réutiliser des dépendances incompatibles.
|
||||
|
||||
Les pushes du dépôt `manus-dashboard` créent une demande atomique prise en charge par `manus-dashboard-self-deploy.path`. Le conteneur ne se redéploie donc jamais lui-même : le service systemd hôte effectue le build et le démarrage du remplacement.
|
||||
|
||||
Après mise à jour Git, installer ou actualiser le service avec :
|
||||
|
||||
```bash
|
||||
sudo ./ops/install-healthcheck.sh
|
||||
```
|
||||
|
||||
## Runner CI de production
|
||||
|
||||
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise l’image locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.
|
||||
|
||||
9
ops/ci-alert-email-dispatch.path
Normal file
9
ops/ci-alert-email-dispatch.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Surveille la file des alertes CI Santinova
|
||||
|
||||
[Path]
|
||||
PathChanged=/opt/manus-deploy/secrets/ci-alerts/outbox
|
||||
Unit=ci-alert-email-dispatch.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
49
ops/ci-alert-email-dispatch.py
Normal file
49
ops/ci-alert-email-dispatch.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expédie les alertes CI mises en file par le dashboard depuis l’hôte de recette."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OUTBOX = Path("/opt/manus-deploy/secrets/ci-alerts/outbox")
|
||||
ARCHIVE = Path("/var/log/manus-deploy/ci-alerts-email-sent.json")
|
||||
|
||||
|
||||
def send_alert(file_path: Path) -> None:
|
||||
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
recipient = str(payload.get("to", "")).strip()
|
||||
subject = str(payload.get("subject", "")).strip()
|
||||
text = str(payload.get("text", "")).strip()
|
||||
if not recipient or "@" not in recipient or not subject or not text:
|
||||
raise ValueError(f"Alerte CI invalide : {file_path.name}")
|
||||
message = EmailMessage()
|
||||
message["To"] = recipient
|
||||
message["From"] = "alertes-ci@itinova.org"
|
||||
message["Subject"] = subject
|
||||
message.set_content(text)
|
||||
subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=message.as_bytes(), check=True, timeout=30)
|
||||
ARCHIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ARCHIVE.write_text(json.dumps({"id": payload.get("id"), "sentAt": payload.get("createdAt")}) + "\n", encoding="utf-8")
|
||||
file_path.unlink()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
failures = []
|
||||
for file_path in sorted(OUTBOX.glob("*.json")):
|
||||
try:
|
||||
send_alert(file_path)
|
||||
except Exception as error: # Le fichier reste en file pour réessai et investigation.
|
||||
print(f"CI_EMAIL_DISPATCH_FAILED {file_path.name}: {error}", file=sys.stderr)
|
||||
failures.append(file_path.name)
|
||||
if failures:
|
||||
return 1
|
||||
print("CI_EMAIL_DISPATCH_OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
8
ops/ci-alert-email-dispatch.service
Normal file
8
ops/ci-alert-email-dispatch.service
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Envoi hôte des alertes CI Santinova vers le DSI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/ci-alert-email-dispatch.py
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Déploie le dashboard depuis l’hôte systemd, jamais depuis le conteneur lui-même.
|
||||
# Le dashboard est redéployé par l’hôte, jamais par le conteneur qu’il remplace.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly APP_DIR="/opt/manus-deploy/apps/manus-dashboard"
|
||||
readonly REQUEST_FILE="${APP_DIR}/.deployment-request"
|
||||
readonly PROCESSING_FILE="${APP_DIR}/.deployment-request.processing"
|
||||
@@ -11,12 +10,8 @@ exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || exit 0
|
||||
[[ -f "${REQUEST_FILE}" ]] || exit 0
|
||||
mv "${REQUEST_FILE}" "${PROCESSING_FILE}"
|
||||
|
||||
restore_request() {
|
||||
[[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"
|
||||
}
|
||||
restore_request() { [[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"; }
|
||||
trap restore_request ERR
|
||||
|
||||
cd "${APP_DIR}"
|
||||
git pull --ff-only origin main
|
||||
docker compose up -d --build
|
||||
|
||||
@@ -6,7 +6,8 @@ runner:
|
||||
envs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
# Cache Gitea Actions persistant, accessible depuis les jobs du réseau Docker web.
|
||||
# Le cache intégré est servi par le runner hôte et accessible par les jobs
|
||||
# depuis le réseau Docker web. Les clés restent définies par chaque workflow.
|
||||
cache:
|
||||
enabled: true
|
||||
dir: /opt/manus-deploy/gitea-runner/cache
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Runner Gitea Actions de production
|
||||
Description=Runner Gitea Actions de recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
9
ops/gitea-token-rotation-apply.path
Normal file
9
ops/gitea-token-rotation-apply.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Surveillance de la validation manuelle du jeton Gitea Santinova
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request
|
||||
Unit=gitea-token-rotation-apply.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
ops/gitea-token-rotation-apply.service
Normal file
11
ops/gitea-token-rotation-apply.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Application manuelle validée du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
12
ops/gitea-token-rotation.service
Normal file
12
ops/gitea-token-rotation.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Préparation contrôlée de la rotation du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Nice=10
|
||||
150
ops/gitea-token-rotation.sh
Normal file
150
ops/gitea-token-rotation.sh
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly COMMAND="${1:-status}"
|
||||
readonly FORCE="${2:-}"
|
||||
readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}"
|
||||
readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}"
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}"
|
||||
readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}"
|
||||
readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token"
|
||||
readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name"
|
||||
readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name"
|
||||
readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation"
|
||||
readonly LOCK_FILE="${STATE_DIR}/rotation.lock"
|
||||
readonly APPROVAL_FILE="${STATE_DIR}/approval.request"
|
||||
readonly TOKEN_PREFIX="santinova-recette"
|
||||
readonly MAX_AGE_DAYS="90"
|
||||
|
||||
install -d -m 0700 "${STATE_DIR}"
|
||||
install -d -m 0750 "$(dirname "${STATUS_FILE}")"
|
||||
exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; }
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g'
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1"
|
||||
local message="$2"
|
||||
local token_name="${3:-}"
|
||||
local temporary
|
||||
temporary="$(mktemp "${STATUS_FILE}.XXXXXX")"
|
||||
chmod 0640 "${temporary}"
|
||||
printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \
|
||||
"$(json_escape "${state}")" \
|
||||
"$(json_escape "${message}")" \
|
||||
"$(json_escape "${token_name}")" \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}"
|
||||
mv "${temporary}" "${STATUS_FILE}"
|
||||
}
|
||||
|
||||
read_env_token() {
|
||||
sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1
|
||||
}
|
||||
|
||||
validate_token() {
|
||||
local token="$1"
|
||||
local response status result=1
|
||||
response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
|
||||
if status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")" \
|
||||
&& [[ "${status}" == "200" ]] \
|
||||
&& grep -q '"login":"manus-admin"' "${response}" \
|
||||
&& status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")" \
|
||||
&& [[ "${status}" == "200" ]] \
|
||||
&& status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")" \
|
||||
&& [[ "${status}" == "200" ]]; then
|
||||
result=0
|
||||
fi
|
||||
rm -f "${response}"
|
||||
return "${result}"
|
||||
}
|
||||
|
||||
rotation_due() {
|
||||
[[ "${FORCE}" == "--force" ]] && return 0
|
||||
[[ -f "${LAST_ROTATION_FILE}" ]] || return 0
|
||||
local last_epoch now_epoch max_age_seconds
|
||||
last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)"
|
||||
now_epoch="$(date +%s)"
|
||||
max_age_seconds="$((MAX_AGE_DAYS * 86400))"
|
||||
(( now_epoch - last_epoch >= max_age_seconds ))
|
||||
}
|
||||
|
||||
prepare_rotation() {
|
||||
if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then
|
||||
local existing_pending
|
||||
existing_pending="$(<"${PENDING_TOKEN_FILE}")"
|
||||
validate_token "${existing_pending}"
|
||||
write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")"
|
||||
echo "GITEA_ROTATION_PENDING"
|
||||
return 0
|
||||
fi
|
||||
if ! rotation_due; then
|
||||
write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)"
|
||||
echo "GITEA_ROTATION_NOT_DUE"
|
||||
return 0
|
||||
fi
|
||||
local current_token token_name pending_token
|
||||
current_token="$(read_env_token)"
|
||||
[[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; }
|
||||
token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)"
|
||||
pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \
|
||||
--username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)"
|
||||
[[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; }
|
||||
printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}"
|
||||
printf '%s' "${token_name}" > "${PENDING_NAME_FILE}"
|
||||
chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
if ! validate_token "${pending_token}"; then
|
||||
rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}"
|
||||
exit 1
|
||||
fi
|
||||
write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}"
|
||||
echo "GITEA_ROTATION_PREPARED"
|
||||
}
|
||||
|
||||
apply_rotation() {
|
||||
trap 'rm -f "${APPROVAL_FILE}"' EXIT
|
||||
[[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; }
|
||||
local pending_token pending_name current_token backup
|
||||
pending_token="$(<"${PENDING_TOKEN_FILE}")"
|
||||
pending_name="$(<"${PENDING_NAME_FILE}")"
|
||||
current_token="$(read_env_token)"
|
||||
validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; }
|
||||
backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")"
|
||||
chmod 0600 "${backup}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}"
|
||||
if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then
|
||||
printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}"
|
||||
fi
|
||||
write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}"
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
rm -f "${backup}"
|
||||
write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}"
|
||||
chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}"
|
||||
write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}"
|
||||
echo "GITEA_ROTATION_APPLIED"
|
||||
}
|
||||
|
||||
case "${COMMAND}" in
|
||||
prepare) prepare_rotation ;;
|
||||
apply) apply_rotation ;;
|
||||
status)
|
||||
if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi
|
||||
;;
|
||||
*) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;;
|
||||
esac
|
||||
11
ops/gitea-token-rotation.timer
Normal file
11
ops/gitea-token-rotation.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Préparation trimestrielle du jeton Gitea Santinova
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-01,04,07,10-01 07:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30m
|
||||
Unit=gitea-token-rotation.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
0
ops/healthcheck-apps.sh
Executable file → Normal file
0
ops/healthcheck-apps.sh
Executable file → Normal file
13
ops/install-ci-alert-email-dispatch.sh
Normal file
13
ops/install-ci-alert-email-dispatch.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly SCRIPT_DIR="/opt/manus-deploy/scripts"
|
||||
readonly OUTBOX_DIR="/opt/manus-deploy/secrets/ci-alerts/outbox"
|
||||
|
||||
install -d -m 0700 "${OUTBOX_DIR}"
|
||||
install -m 0700 "${APP_DIR}/ops/ci-alert-email-dispatch.py" "${SCRIPT_DIR}/ci-alert-email-dispatch.py"
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.service" /etc/systemd/system/ci-alert-email-dispatch.service
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.path" /etc/systemd/system/ci-alert-email-dispatch.path
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ci-alert-email-dispatch.path
|
||||
@@ -1,22 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
|
||||
# Installe et enregistre le runner de recette depuis les fichiers versionnés du dashboard.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
|
||||
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
|
||||
readonly GITEA_CONTAINER="gitea"
|
||||
readonly GITEA_URL="https://git.santinova-soft.org"
|
||||
readonly RUNNER_NAME="production-docker-runner"
|
||||
readonly GITEA_URL="https://git.recette.santinova-soft.org"
|
||||
readonly RUNNER_NAME="recette-docker-runner"
|
||||
readonly RUNNER_IMAGE="gitea-runner-node:22"
|
||||
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
|
||||
readonly LABEL_FILE="${RUNNER_DIR}/labels"
|
||||
|
||||
install -d -m 0750 "${RUNNER_DIR}" "${RUNNER_DIR}/cache"
|
||||
install -d -m 0750 "${RUNNER_DIR}"
|
||||
install -d -m 0750 "${RUNNER_DIR}/cache"
|
||||
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
|
||||
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
|
||||
|
||||
# Migration unique : les versions antérieures enregistraient le runner dans APP_DIR.
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" && -f "${APP_DIR}/.runner" ]]; then
|
||||
mv "${APP_DIR}/.runner" "${RUNNER_DIR}/.runner"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
|
||||
systemctl stop gitea-act-runner.service 2>/dev/null || true
|
||||
rm -f "${RUNNER_DIR}/.runner"
|
||||
|
||||
18
ops/install-gitea-token-rotation.sh
Normal file
18
ops/install-gitea-token-rotation.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path
|
||||
install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation
|
||||
install -d -m 0750 /var/log/manus-deploy
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-token-rotation.timer
|
||||
systemctl enable --now gitea-token-rotation-apply.path
|
||||
systemctl is-enabled gitea-token-rotation.timer
|
||||
systemctl is-enabled gitea-token-rotation-apply.path
|
||||
systemctl list-timers gitea-token-rotation.timer --no-pager
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Surveille les demandes de redéploiement du Manus Dashboard
|
||||
Description=Surveille les demandes de redéploiement du Manus Dashboard recette
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/apps/manus-dashboard/.deployment-request
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Redéploiement hôte du Manus Dashboard
|
||||
Description=Redéploiement hôte du Manus Dashboard recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
|
||||
48
ops/provision-dashboard-alert-token.sh
Normal file
48
ops/provision-dashboard-alert-token.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
|
||||
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}"
|
||||
|
||||
backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
|
||||
temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"
|
||||
chmod 0600 "${backup}" "${temporary}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
|
||||
cleanup() {
|
||||
rm -f "${backup}" "${temporary}"
|
||||
unset token result
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')"
|
||||
token="${result%%$'\t'*}"
|
||||
token_id="${result##*$'\t'}"
|
||||
[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; }
|
||||
|
||||
awk -v value="${token}" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next }
|
||||
{ print }
|
||||
END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value }
|
||||
' "${DASHBOARD_ENV_FILE}" > "${temporary}"
|
||||
mv "${temporary}" "${DASHBOARD_ENV_FILE}"
|
||||
chmod 0600 "${DASHBOARD_ENV_FILE}"
|
||||
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 8
|
||||
health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")"
|
||||
if [[ "${health}" != "204" ]]; then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null
|
||||
echo "DASHBOARD_ALERT_TOKEN_PROVISIONED"
|
||||
116
ops/setup-gitea-webhooks.py
Normal file
116
ops/setup-gitea-webhooks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/")
|
||||
GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin")
|
||||
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/")
|
||||
DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard"))
|
||||
ENV_FILE = DASHBOARD_APP_DIR / ".env"
|
||||
WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea"
|
||||
|
||||
|
||||
def read_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def update_env(key: str, value: str) -> None:
|
||||
lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else []
|
||||
updated: list[str] = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if line.startswith(f"{key}="):
|
||||
updated.append(f"{key}={value}")
|
||||
found = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not found:
|
||||
updated.append(f"{key}={value}")
|
||||
temporary = ENV_FILE.with_suffix(".env.next")
|
||||
temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(ENV_FILE)
|
||||
|
||||
|
||||
def api(token: str, method: str, path: str, payload: dict | None = None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{GITEA_URL}/api/v1{path}",
|
||||
method=method,
|
||||
data=data,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode(errors="replace")
|
||||
raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error
|
||||
|
||||
|
||||
def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]:
|
||||
if values.get("WEBHOOK_SECRET"):
|
||||
return values, False
|
||||
update_env("WEBHOOK_SECRET", secrets.token_hex(32))
|
||||
return read_env(), True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
values, generated = ensure_dashboard_secret(read_env())
|
||||
token = values.get("GITEA_TOKEN", "")
|
||||
secret = values.get("WEBHOOK_SECRET", "")
|
||||
if not token or not secret:
|
||||
raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.")
|
||||
if generated:
|
||||
subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True)
|
||||
time.sleep(8)
|
||||
identity = api(token, "GET", "/user")
|
||||
if identity.get("login") != GITEA_USER:
|
||||
raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.")
|
||||
repositories = api(token, "GET", "/user/repos?limit=100&page=1") or []
|
||||
configured = 0
|
||||
for repository in repositories:
|
||||
owner = repository.get("owner", {}).get("login")
|
||||
name = repository.get("name")
|
||||
if owner != GITEA_USER or not name:
|
||||
continue
|
||||
hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or []
|
||||
existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None)
|
||||
payload = {
|
||||
"active": True,
|
||||
"branch_filter": "main",
|
||||
"config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret},
|
||||
"events": ["push", "workflow_run"],
|
||||
}
|
||||
if existing:
|
||||
api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload)
|
||||
else:
|
||||
api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload})
|
||||
configured += 1
|
||||
print(f"WEBHOOK_OK {owner}/{name}")
|
||||
if configured == 0:
|
||||
raise RuntimeError("Aucun dépôt Gitea n’a été configuré.")
|
||||
print(f"GITEA_WEBHOOKS_CONFIGURED {configured}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -25,7 +25,7 @@ RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh
|
||||
RUN git config --global user.email "admin@santinova-soft.org" && \
|
||||
git config --global user.name "Manus Dashboard"
|
||||
|
||||
# Installer les dépendances backend depuis un verrou versionné pour un build déterministe.
|
||||
# Installer le backend depuis un verrou versionné pour un build déterministe.
|
||||
COPY backend/package.json backend/package-lock.json ./backend/
|
||||
WORKDIR /app/backend
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js"
|
||||
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const config = require('./config');
|
||||
const { createRepo, giteaClient } = require('./gitea');
|
||||
const { refreshApps, writeAppManifest } = require('./app-registry');
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
async function createGiteaGitAuth() {
|
||||
if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.');
|
||||
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-'));
|
||||
const tokenPath = path.join(directory, 'token');
|
||||
const askpassPath = path.join(directory, 'askpass.sh');
|
||||
await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 });
|
||||
await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 });
|
||||
return {
|
||||
env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' },
|
||||
cleanup: () => fsp.rm(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGiteaRepoUrl(value) {
|
||||
const url = new URL(value);
|
||||
const configured = new URL(config.gitea.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== configured.hostname) {
|
||||
throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ============ TEMPLATES DOCKERFILE ============
|
||||
|
||||
const DOCKERFILE_TEMPLATES = {
|
||||
@@ -436,6 +465,7 @@ async function createApplication(params, logCallback) {
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const appDir = path.join(config.appsBasePath, appId);
|
||||
const srcDir = path.join(appDir, 'src');
|
||||
let gitAuth = null;
|
||||
|
||||
const log = (msg) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
@@ -444,6 +474,7 @@ async function createApplication(params, logCallback) {
|
||||
};
|
||||
|
||||
try {
|
||||
gitAuth = await createGiteaGitAuth();
|
||||
// 1. Créer le répertoire de l'application
|
||||
log(`Création du répertoire ${appDir}...`);
|
||||
await fsp.mkdir(appDir, { recursive: true });
|
||||
@@ -455,11 +486,8 @@ async function createApplication(params, logCallback) {
|
||||
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);
|
||||
const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
|
||||
await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
|
||||
log('Clonage terminé.');
|
||||
|
||||
// Extraire le nom du repo depuis l'URL
|
||||
@@ -503,10 +531,11 @@ async function createApplication(params, logCallback) {
|
||||
|
||||
// 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`;
|
||||
const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${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
|
||||
`cd ${shellQuote(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 ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`,
|
||||
120000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Code poussé sur Gitea.');
|
||||
}
|
||||
@@ -548,7 +577,8 @@ async function createApplication(params, logCallback) {
|
||||
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
|
||||
60000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Dockerfile commité et poussé sur Gitea.');
|
||||
}
|
||||
@@ -642,12 +672,14 @@ async function createApplication(params, logCallback) {
|
||||
} catch (err) {
|
||||
log(`ERREUR: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await gitAuth?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(cmd, timeout = 60000) {
|
||||
function execCommand(cmd, timeout = 60000, env = process.env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||
} else {
|
||||
|
||||
@@ -142,10 +142,6 @@ function findAppDirectoryByRepo(config, repositoryName) {
|
||||
return app ? app.directory : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le manifeste complet afin que le webhook applique les règles de
|
||||
* promotion déclarées par l'application (notamment ci.required).
|
||||
*/
|
||||
function findAppByRepo(config, repositoryName) {
|
||||
const { apps } = refreshApps(config);
|
||||
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
|
||||
|
||||
117
src/backend/src/ci-alerts.js
Normal file
117
src/backend/src/ci-alerts.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
|
||||
const MAX_ALERTS = 100;
|
||||
|
||||
function formatEmailAlert(alert) {
|
||||
return [
|
||||
'Une validation CI a échoué sur l’infrastructure Santinova.',
|
||||
'',
|
||||
`Dépôt : ${alert.repository}`,
|
||||
`Workflow : ${alert.workflow}`,
|
||||
`Conclusion : ${alert.conclusion}`,
|
||||
`Commit : ${alert.commit}`,
|
||||
`Date : ${alert.occurredAt}`,
|
||||
alert.url ? `Détail : ${alert.url}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function enqueueDsiEmail(alert) {
|
||||
if (!config.dsiAlerts.email) return false;
|
||||
const queueRoot = config.dsiAlerts.emailQueue;
|
||||
const safeId = String(alert.id).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const queueFile = queueRoot.endsWith('.json')
|
||||
? queueRoot
|
||||
: path.join(queueRoot, `${safeId}.json`);
|
||||
const temporary = `${queueFile}.${process.pid}.tmp`;
|
||||
const payload = {
|
||||
id: alert.id,
|
||||
to: config.dsiAlerts.email,
|
||||
subject: `[Santinova] Échec CI — ${alert.repository}`,
|
||||
text: formatEmailAlert(alert),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(queueFile), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(temporary, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(temporary, queueFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeWorkflowFailure(payload, deliveryId = null) {
|
||||
if (payload?.action !== 'completed') return null;
|
||||
const run = payload?.workflow_run || {};
|
||||
const conclusion = String(run.conclusion || '').toLowerCase();
|
||||
if (!conclusion || conclusion === 'success') return null;
|
||||
const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu';
|
||||
const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu';
|
||||
return {
|
||||
id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`,
|
||||
repository,
|
||||
workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI',
|
||||
commit,
|
||||
conclusion,
|
||||
url: run.html_url || null,
|
||||
occurredAt: run.completed_at || new Date().toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
dsiNotificationSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readCiAlerts() {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCiAlert(alert) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id);
|
||||
alerts.unshift(alert);
|
||||
const temporary = `${ALERTS_FILE}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 });
|
||||
fs.renameSync(temporary, ALERTS_FILE);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function notifyDsi(alert) {
|
||||
if (enqueueDsiEmail(alert)) return true;
|
||||
if (!config.dsiAlerts.token) {
|
||||
console.error('[CI Alert] Aucun canal direct DSI configuré : e-mail et IA_REFERENTIEL_TOKEN absents.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${config.dsiAlerts.token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: alert.repository,
|
||||
workflow: alert.workflow,
|
||||
commit: alert.commit,
|
||||
conclusion: alert.conclusion,
|
||||
url: alert.url,
|
||||
occurredAt: alert.occurredAt,
|
||||
deliveryId: alert.id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[CI Alert] Notification DSI refusée (${response.status}).`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CI Alert] Notification DSI impossible:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi, enqueueDsiEmail };
|
||||
@@ -25,8 +25,16 @@ module.exports = {
|
||||
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,
|
||||
productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org',
|
||||
productionToken: process.env.GITEA_TOKEN_PROD || process.env.GITEA_TOKEN || null,
|
||||
},
|
||||
|
||||
dsiAlerts: {
|
||||
url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org',
|
||||
token: process.env.IA_REFERENTIEL_TOKEN || null,
|
||||
email: process.env.DSI_ALERT_EMAIL || null,
|
||||
emailQueue: process.env.CI_ALERT_EMAIL_QUEUE || '/opt/manus-deploy/secrets/ci-alerts/outbox',
|
||||
},
|
||||
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
|
||||
@@ -2,18 +2,9 @@ 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 } }
|
||||
),
|
||||
headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -76,7 +67,6 @@ function toDurationSeconds(startedAt, completedAt) {
|
||||
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
|
||||
}
|
||||
|
||||
/** Normalise les exécutions CI pour le webhook et le dashboard. */
|
||||
function normalizeWorkflowRun(run) {
|
||||
// Gitea 1.25 renvoie started_at/completed_at (et non les champs GitHub).
|
||||
// Les aliases conservent la compatibilité avec d’éventuelles versions futures.
|
||||
@@ -99,9 +89,7 @@ function normalizeWorkflowRun(run) {
|
||||
|
||||
async function getWorkflowRuns(owner, repo, limit = 10) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
|
||||
params: { limit, page: 1 },
|
||||
});
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, { params: { limit, page: 1 } });
|
||||
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
|
||||
@@ -123,9 +111,7 @@ function median(values) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une synthèse déterministe sur les derniers runs CI.
|
||||
* Les comparaisons ne sont calculées qu’avec deux fenêtres d’au moins deux runs
|
||||
* pour ne pas transformer une variation isolée en tendance.
|
||||
* Agrège les derniers runs CI sans conclure à une tendance avec un échantillon insuffisant.
|
||||
*/
|
||||
function calculateCiSummary(runs) {
|
||||
const completed = runs.filter((run) => run.status === 'completed');
|
||||
@@ -143,7 +129,6 @@ function calculateCiSummary(runs) {
|
||||
: changePercent <= -10 ? 'faster'
|
||||
: changePercent >= 10 ? 'slower'
|
||||
: 'stable';
|
||||
|
||||
return {
|
||||
sampleSize: completed.length,
|
||||
successful: successful.length,
|
||||
@@ -161,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) {
|
||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||
}
|
||||
|
||||
async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) {
|
||||
if (!token) return { present: false, url: null, version: null };
|
||||
const client = axios.create({
|
||||
baseURL: `${baseUrl}/api/v1`,
|
||||
headers: { Authorization: `token ${token}` },
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 7000,
|
||||
});
|
||||
try {
|
||||
const [repoResponse, commitsResponse] = await Promise.all([
|
||||
client.get(`/repos/${owner}/${repo}`),
|
||||
client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }),
|
||||
]);
|
||||
const commit = commitsResponse.data?.[0];
|
||||
const sha = commit?.sha?.substring(0, 7) || null;
|
||||
const date = commit?.commit?.author?.date
|
||||
? new Date(commit.commit.author.date).toLocaleDateString('fr-FR')
|
||||
: null;
|
||||
return {
|
||||
present: Boolean(repoResponse.data?.id),
|
||||
url: `${publicBaseUrl}/${owner}/${repo}`,
|
||||
version: sha && date ? `${sha} (${date})` : sha,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message);
|
||||
return { present: false, url: null, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
@@ -207,6 +221,7 @@ module.exports = {
|
||||
getWorkflowRuns,
|
||||
getWorkflowRunSummary,
|
||||
getWorkflowRunForCommit,
|
||||
getRepoSnapshot,
|
||||
normalizeWorkflowRun,
|
||||
calculateCiSummary,
|
||||
listRepos,
|
||||
|
||||
@@ -63,6 +63,8 @@ async function checkApp(appConfig) {
|
||||
id: appConfig.id,
|
||||
name: appConfig.name,
|
||||
description: appConfig.description || '',
|
||||
// Préserve la catégorie du manifeste pour le rendu différencié des services INFRA.
|
||||
category: appConfig.category || 'SANTINOVA',
|
||||
urls: appConfig.urls,
|
||||
containerName: appConfig.containerName,
|
||||
lastCheck: new Date().toISOString(),
|
||||
|
||||
@@ -29,6 +29,12 @@ app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
// Les portails sont servis depuis un sous-domaine distinct. Seules les routes
|
||||
// publiques peuvent être lues depuis cette origine ; l’API authentifiée reste inchangée.
|
||||
app.use('/api/public', (req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
next();
|
||||
});
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
@@ -99,6 +105,9 @@ async function runHealthChecks() {
|
||||
|
||||
// Démarrage
|
||||
async function start() {
|
||||
if (!config.gitea.token) {
|
||||
throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.');
|
||||
}
|
||||
await initAuth();
|
||||
|
||||
// Charger les applications dynamiques
|
||||
|
||||
@@ -2,11 +2,13 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { authenticate, authMiddleware } = require('./auth');
|
||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea');
|
||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||
const { readCiAlerts } = require('./ci-alerts');
|
||||
const config = require('./config');
|
||||
|
||||
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
||||
@@ -399,7 +401,6 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Dernières validations CI avec statut et durée, utilisées par l'écran Gitea.
|
||||
router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const runs = await getWorkflowRuns(req.params.owner, req.params.repo, 10);
|
||||
@@ -409,7 +410,6 @@ router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Synthèse historique : taux de succès, médiane et tendance des validations CI.
|
||||
router.get('/gitea/repos/:owner/:repo/actions/summary', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
res.json(await getWorkflowRunSummary(req.params.owner, req.params.repo));
|
||||
@@ -447,8 +447,9 @@ router.get('/public/apps', (req, res) => {
|
||||
// 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();
|
||||
// Le portail répartit lui-même les applications par onglet, y compris INFRA.
|
||||
// L’API expose donc toutes les applications du registre local, sans en masquer.
|
||||
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 {
|
||||
@@ -543,17 +544,63 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ci-alerts', authMiddleware, (req, res) => {
|
||||
res.json(readCiAlerts());
|
||||
});
|
||||
|
||||
const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh';
|
||||
const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json';
|
||||
const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request';
|
||||
|
||||
function readGiteaRotationStatus() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
function runGiteaRotation(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => {
|
||||
if (error) return reject(new Error((stderr || stdout || error.message).trim()));
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => {
|
||||
res.json(readGiteaRotationStatus());
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await runGiteaRotation(['prepare', '--force']);
|
||||
res.json(readGiteaRotationStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message, status: readGiteaRotationStatus() });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => {
|
||||
const status = readGiteaRotationStatus();
|
||||
if (status.state !== 'pending_approval') {
|
||||
return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' });
|
||||
});
|
||||
|
||||
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_PROD_EXTERNAL = config.gitea.productionUrl;
|
||||
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;
|
||||
const GITEA_TOKEN_PRD = config.gitea.productionToken;
|
||||
|
||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||
function getInventoryApps() {
|
||||
@@ -564,41 +611,6 @@ function getInventoryApps() {
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -606,8 +618,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
const results = await Promise.all(
|
||||
INVENTORY_APPS.map(async (app) => {
|
||||
const [repoRecette, repoProd] = await Promise.all([
|
||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||
getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
|
||||
getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const { findAppByRepo } = require('./app-registry');
|
||||
const { getWorkflowRunForCommit } = require('./gitea');
|
||||
const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -27,9 +28,8 @@ const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERV
|
||||
const deployingApps = new Set();
|
||||
|
||||
/**
|
||||
* Le conteneur ne peut pas se recréer lui-même : Docker interrompt son client
|
||||
* `docker compose` au moment où le conteneur courant est arrêté. Ce cas est
|
||||
* donc délégué au service systemd hôte manus-dashboard-self-deploy.path.
|
||||
* Un conteneur ne peut pas se recréer lui-même : il interromprait son propre
|
||||
* client Docker. Le service systemd hôte surveille la demande atomique créée ici.
|
||||
*/
|
||||
function isHostManagedDeployment(appName) {
|
||||
return appName === DASHBOARD_APP_ID;
|
||||
@@ -37,13 +37,7 @@ function isHostManagedDeployment(appName) {
|
||||
|
||||
function requestDashboardHostDeployment({ branch, commitHash, committer }) {
|
||||
const temporaryRequest = `${DASHBOARD_DEPLOY_REQUEST}.${process.pid}.tmp`;
|
||||
const request = {
|
||||
branch,
|
||||
commitHash,
|
||||
committer,
|
||||
requestedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(temporaryRequest, JSON.stringify(request, null, 2), { mode: 0o600 });
|
||||
fs.writeFileSync(temporaryRequest, JSON.stringify({ branch, commitHash, committer, requestedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
|
||||
}
|
||||
|
||||
@@ -64,30 +58,22 @@ function wait(delay) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une application déclarant ci.required ne peut être déployée que si le commit
|
||||
* reçu dispose d’un run Gitea terminé avec succès.
|
||||
*/
|
||||
async function waitForSuccessfulCi(app, commitHash) {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
|
||||
let lastRun = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
|
||||
if (lastRun?.status === 'completed') {
|
||||
return {
|
||||
allowed: lastRun.conclusion === 'success',
|
||||
run: lastRun,
|
||||
reason: lastRun.conclusion === 'success'
|
||||
? null
|
||||
: `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
|
||||
reason: lastRun.conclusion === 'success' ? null : `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
|
||||
};
|
||||
}
|
||||
await wait(CI_GATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
run: lastRun,
|
||||
@@ -155,23 +141,12 @@ async function gateAndDeploy(app, branch, commitHash, committer, broadcast) {
|
||||
if (shouldRequireCi(app)) {
|
||||
const ci = await waitForSuccessfulCi(app, commitHash);
|
||||
if (!ci.allowed) {
|
||||
const status = {
|
||||
app: app.directory,
|
||||
branch,
|
||||
commit: commitHash,
|
||||
committer,
|
||||
status: 'blocked',
|
||||
timestamp: new Date().toISOString(),
|
||||
ci: ci.run,
|
||||
reason: ci.reason,
|
||||
};
|
||||
console.warn(`[Webhook] Déploiement bloqué pour ${app.directory}: ${ci.reason}`);
|
||||
const status = { app: app.directory, branch, commit: commitHash, committer, status: 'blocked', timestamp: new Date().toISOString(), ci: ci.run, reason: ci.reason };
|
||||
saveDeploymentStatus(app.directory, status);
|
||||
broadcast?.({ type: 'deploy_blocked', data: status });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isHostManagedDeployment(app.directory)) {
|
||||
requestDashboardHostDeployment({ branch, commitHash, committer });
|
||||
return;
|
||||
@@ -186,6 +161,22 @@ router.post('/gitea', (req, res) => {
|
||||
return res.status(401).json({ error: 'Signature invalide' });
|
||||
}
|
||||
const event = req.headers['x-gitea-event'] || 'unknown';
|
||||
if (event === 'workflow_run') {
|
||||
const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null);
|
||||
if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' });
|
||||
saveCiAlert(alert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: alert });
|
||||
res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id });
|
||||
setImmediate(async () => {
|
||||
const sent = await notifyDsi(alert);
|
||||
if (sent) {
|
||||
const notifiedAlert = { ...alert, dsiNotificationSent: true };
|
||||
saveCiAlert(notifiedAlert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event !== 'push') {
|
||||
return res.status(200).json({ message: `Événement ${event} ignoré` });
|
||||
}
|
||||
|
||||
22
src/backend/test/ci-alerts.test.js
Normal file
22
src/backend/test/ci-alerts.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const assert = require('assert');
|
||||
const { normalizeWorkflowFailure } = require('../src/ci-alerts');
|
||||
|
||||
const failure = normalizeWorkflowFailure({
|
||||
action: 'completed',
|
||||
repository: { full_name: 'manus-admin/referentiel-ia' },
|
||||
workflow: { name: 'Validation CI' },
|
||||
workflow_run: {
|
||||
id: 42,
|
||||
conclusion: 'failure',
|
||||
head_sha: '1234567890abcdef',
|
||||
completed_at: '2026-08-21T12:00:00.000Z',
|
||||
html_url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
},
|
||||
}, 'delivery-42');
|
||||
|
||||
assert.equal(failure.id, 'delivery-42');
|
||||
assert.equal(failure.repository, 'manus-admin/referentiel-ia');
|
||||
assert.equal(failure.conclusion, 'failure');
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null);
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null);
|
||||
console.log('OK ci alerts');
|
||||
31
src/backend/test/ci-email-alert.test.js
Normal file
31
src/backend/test/ci-email-alert.test.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-email-alert-'));
|
||||
process.env.DSI_ALERT_EMAIL = 'dsi@example.invalid';
|
||||
process.env.CI_ALERT_EMAIL_QUEUE = path.join(temporaryDirectory, 'queued-alert.json');
|
||||
|
||||
const { enqueueDsiEmail } = require('../src/ci-alerts');
|
||||
|
||||
const queued = enqueueDsiEmail({
|
||||
id: 'ci-email-test',
|
||||
repository: 'manus-admin/referentiel-ia',
|
||||
workflow: 'Validation CI',
|
||||
commit: 'abc1234',
|
||||
conclusion: 'failure',
|
||||
url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
occurredAt: '2026-08-21T18:00:00Z',
|
||||
});
|
||||
|
||||
assert.equal(queued, true);
|
||||
const payload = JSON.parse(fs.readFileSync(process.env.CI_ALERT_EMAIL_QUEUE, 'utf8'));
|
||||
assert.equal(payload.to, 'dsi@example.invalid');
|
||||
assert.equal(payload.id, 'ci-email-test');
|
||||
assert.match(payload.subject, /Échec CI/);
|
||||
assert.match(payload.text, /referentiel-ia/);
|
||||
assert.equal(fs.statSync(process.env.CI_ALERT_EMAIL_QUEUE).mode & 0o777, 0o600);
|
||||
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
console.log('OK ci email alert');
|
||||
13
src/backend/test/dashboard-alert-token.test.js
Normal file
13
src/backend/test/dashboard-alert-token.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8');
|
||||
|
||||
assert.match(source, /dashboard-alert-token\.ts prepare/);
|
||||
assert.match(source, /alerts\/ci\/health/);
|
||||
assert.match(source, /dashboard-alert-token\.ts finalize/);
|
||||
assert.match(source, /rollback effectué/);
|
||||
assert.doesNotMatch(source, /GITEA_PASSWORD/);
|
||||
console.log('OK dashboard alert token');
|
||||
23
src/backend/test/gitea-auth.test.js
Normal file
23
src/backend/test/gitea-auth.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const files = [
|
||||
'backend/src/config.js',
|
||||
'backend/src/gitea.js',
|
||||
'backend/src/app-creator.js',
|
||||
'backend/src/routes.js',
|
||||
'docker-compose.yml',
|
||||
];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`);
|
||||
assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`);
|
||||
}
|
||||
|
||||
const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8');
|
||||
assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/);
|
||||
assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false);
|
||||
console.log('OK gitea auth');
|
||||
38
src/backend/test/healthcheck-category.test.js
Normal file
38
src/backend/test/healthcheck-category.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
|
||||
// Le test est autonome : aucune dépendance externe ni requête réseau n’est nécessaire.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function mockAxios(request, parent, isMain) {
|
||||
if (request === 'axios') return { get: async () => ({ status: 200 }) };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
// Le test isole Docker : il valide le contrat de données émis par healthcheck.
|
||||
const dockerPath = require.resolve('../src/docker');
|
||||
require.cache[dockerPath] = {
|
||||
id: dockerPath,
|
||||
filename: dockerPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
getContainerInfo: async () => ({ running: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const { checkApp } = require('../src/healthcheck');
|
||||
|
||||
(async () => {
|
||||
const result = await checkApp({
|
||||
id: 'referentiel-ia',
|
||||
name: 'Référentiel IA Santinova',
|
||||
category: 'INFRA',
|
||||
urls: {},
|
||||
containerName: 'referentiel-ia',
|
||||
});
|
||||
|
||||
assert.equal(result.category, 'INFRA');
|
||||
console.log('healthcheck-category: OK');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
20
src/backend/test/token-rotation.test.js
Normal file
20
src/backend/test/token-rotation.test.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8');
|
||||
const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8');
|
||||
const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8');
|
||||
|
||||
assert.match(rotation, /generate-access-token/);
|
||||
assert.match(rotation, /pending_approval/);
|
||||
assert.match(rotation, /GITEA_ROTATION_APPLIED/);
|
||||
assert.match(rotation, /chmod 0600/);
|
||||
assert.match(rotation, /rollback/);
|
||||
assert.doesNotMatch(rotation, /GITEA_PASSWORD/);
|
||||
assert.doesNotMatch(rotation, /trap .*RETURN/);
|
||||
assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/);
|
||||
assert.match(timer, /Persistent=true/);
|
||||
assert.match(approvalPath, /PathExists=.*approval\.request/);
|
||||
console.log('OK token rotation');
|
||||
14
src/backend/test/webhook-setup.test.js
Normal file
14
src/backend/test/webhook-setup.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8');
|
||||
const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
|
||||
|
||||
assert.match(setup, /"events": \["push", "workflow_run"\]/);
|
||||
assert.match(setup, /secrets\.token_hex\(32\)/);
|
||||
assert.match(setup, /method=method/);
|
||||
assert.doesNotMatch(setup, /GITEA_PASSWORD/);
|
||||
assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/);
|
||||
console.log('OK webhook setup');
|
||||
@@ -1,8 +1,3 @@
|
||||
/*
|
||||
* Un conteneur ne peut pas recréer fiablement son propre conteneur Docker :
|
||||
* le client `docker compose` est arrêté en même temps que lui. Le webhook doit
|
||||
* donc déléguer ce cas précis à un service systemd hôte.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const { isHostManagedDeployment, shouldRequireCi } = require('../src/webhook');
|
||||
|
||||
|
||||
@@ -13,10 +13,14 @@ services:
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||
- GITEA_RECETTE_URL=http://gitea:3000
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur}
|
||||
- IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
|
||||
- IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-}
|
||||
- DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-}
|
||||
- CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard - Gestion des Applications</title>
|
||||
<title>Dashboard Recette - Gestion des Applications</title>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,14 +9,17 @@ import DockerPage from './pages/DockerPage';
|
||||
import DocumentationPage from './pages/DocumentationPage';
|
||||
import InventairePage from './pages/InventairePage';
|
||||
import TerminalPage from './pages/TerminalPage';
|
||||
import CiPage from './pages/CiPage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import CiAlertBanner from './components/CiAlertBanner';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||
import { getMe, getApps, getCiAlerts, 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([]);
|
||||
const [ciAlerts, setCiAlerts] = useState([]);
|
||||
// Vérifier l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
@@ -25,6 +28,7 @@ export default function App() {
|
||||
.then(() => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
@@ -43,11 +47,22 @@ export default function App() {
|
||||
console.error('Erreur chargement apps:', err);
|
||||
}
|
||||
};
|
||||
const fetchCiAlerts = async () => {
|
||||
try {
|
||||
const response = await getCiAlerts();
|
||||
setCiAlerts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement alertes CI:', error);
|
||||
}
|
||||
};
|
||||
// WebSocket handler
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.type === 'health_update') {
|
||||
setApps(data.data);
|
||||
}
|
||||
if (data.type === 'ci_alert') {
|
||||
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
|
||||
}
|
||||
}, []);
|
||||
const { connected: wsConnected } = useWebSocket(
|
||||
authenticated ? handleWsMessage : null
|
||||
@@ -55,6 +70,7 @@ export default function App() {
|
||||
const handleLogin = (data) => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -65,6 +81,7 @@ export default function App() {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
setAuthenticated(false);
|
||||
setApps([]);
|
||||
setCiAlerts([]);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -100,6 +117,8 @@ export default function App() {
|
||||
return <DashboardPage apps={apps} />;
|
||||
case 'apps':
|
||||
return <AppsPage apps={apps} onRefresh={fetchApps} />;
|
||||
case 'ci':
|
||||
return <CiPage apps={apps} />;
|
||||
case 'deployments':
|
||||
return <DeploymentsPage />;
|
||||
case 'gitea':
|
||||
@@ -126,7 +145,10 @@ export default function App() {
|
||||
onLogout={handleLogout}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
<main className="ml-64 p-8">{renderPage()}</main>
|
||||
<main className="ml-64 p-8">
|
||||
<CiAlertBanner alert={ciAlerts[0]} />
|
||||
{renderPage()}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import CiBadgeInline from './CiBadgeInline';
|
||||
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@@ -137,6 +138,7 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{app.name}</h3>
|
||||
<StatusBadge status={app.status} />
|
||||
<CiBadgeInline app={app} />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{app.description || app.id}
|
||||
|
||||
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function CiAlertBanner({ alert }) {
|
||||
if (!alert) return null;
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-100 shadow-lg shadow-red-950/20" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="font-semibold">Échec CI — {alert.repository}</p>
|
||||
<p className="mt-1 text-sm text-red-100/80">{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}</p>
|
||||
<p className="mt-1 text-xs text-red-100/60">Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{alert.url && (
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary flex shrink-0 items-center gap-2">
|
||||
Voir le workflow <ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/frontend/src/components/CiBadgeInline.jsx
Normal file
91
src/frontend/src/components/CiBadgeInline.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, XCircle, Clock, Shield } from 'lucide-react';
|
||||
import { getGiteaWorkflowRuns } from '../utils/api';
|
||||
import CiRunsModal from './CiRunsModal';
|
||||
|
||||
/**
|
||||
* Badge CI compact affiché dans le header de chaque AppCard.
|
||||
* Récupère le dernier run CI et affiche un badge coloré.
|
||||
* Cliquable : ouvre une modale avec l'historique des runs.
|
||||
*/
|
||||
export default function CiBadgeInline({ app }) {
|
||||
const [lastRun, setLastRun] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
getGiteaWorkflowRuns(owner, repo)
|
||||
.then((res) => {
|
||||
const runs = res.data || [];
|
||||
if (runs.length > 0) setLastRun(runs[0]);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [app.id]);
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-dark-700 text-gray-500 animate-pulse">
|
||||
<Clock className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let badge;
|
||||
|
||||
if (!lastRun) {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-500/15 text-gray-500 border border-gray-500/20 cursor-pointer hover:bg-gray-500/25 transition-colors"
|
||||
title="Cliquer pour voir l'historique CI"
|
||||
>
|
||||
<Shield className="w-3 h-3" /> Pas de CI
|
||||
</span>
|
||||
);
|
||||
} else if (lastRun.conclusion === 'success') {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer hover:bg-emerald-500/25 transition-colors"
|
||||
title={`CI verte — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
} else if (lastRun.conclusion === 'failure') {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/25 transition-colors"
|
||||
title={`CI en échec — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<XCircle className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse cursor-pointer hover:bg-amber-500/25 transition-colors"
|
||||
title={`CI en cours — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<Clock className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{badge}
|
||||
{showModal && <CiRunsModal app={app} onClose={() => setShowModal(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
src/frontend/src/components/CiRunsModal.jsx
Normal file
185
src/frontend/src/components/CiRunsModal.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, CheckCircle, XCircle, Clock, ExternalLink, GitCommit, Calendar, Timer } from 'lucide-react';
|
||||
import { getGiteaWorkflowRuns } from '../utils/api';
|
||||
|
||||
/**
|
||||
* Formate une durée en secondes en texte lisible.
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds && seconds !== 0) return '—';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date ISO en format local français.
|
||||
*/
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return '—';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge de conclusion d'un run.
|
||||
*/
|
||||
function RunStatusBadge({ status, conclusion }) {
|
||||
if (conclusion === 'success') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Succès
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (conclusion === 'failure') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<XCircle className="w-3 h-3" /> Échec
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'running' || status === 'waiting') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse">
|
||||
<Clock className="w-3 h-3" /> En cours
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
|
||||
<Clock className="w-3 h-3" /> {conclusion || status || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modale affichant l'historique détaillé des derniers runs CI d'une application.
|
||||
* Ouverte au clic sur un badge CI.
|
||||
*/
|
||||
export default function CiRunsModal({ app, onClose }) {
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
getGiteaWorkflowRuns(owner, repo)
|
||||
.then((res) => {
|
||||
setRuns(res.data || res || []);
|
||||
})
|
||||
.catch(() => setRuns([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [app.id]);
|
||||
|
||||
// Fermer avec Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-dark-900 border border-dark-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Historique CI</h3>
|
||||
<p className="text-sm text-gray-400 mt-0.5">{app.name} — {app.giteaRepo || app.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-dark-700 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-12 text-gray-400">
|
||||
<Clock className="w-5 h-5 animate-spin" />
|
||||
Chargement de l'historique...
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Aucun run CI trouvé pour cette application.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runs.map((run, idx) => (
|
||||
<div
|
||||
key={run.id || idx}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
run.conclusion === 'success'
|
||||
? 'border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10'
|
||||
: run.conclusion === 'failure'
|
||||
? 'border-red-500/20 bg-red-500/5 hover:bg-red-500/10'
|
||||
: 'border-dark-600 bg-dark-800/50 hover:bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono text-gray-500">#{run.id}</span>
|
||||
<RunStatusBadge status={run.status} conclusion={run.conclusion} />
|
||||
</div>
|
||||
{run.url && (
|
||||
<a
|
||||
href={run.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" /> Voir sur Gitea
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>{formatDate(run.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>{formatDuration(run.durationSeconds)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitCommit className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="font-mono">{run.commit ? run.commit.slice(0, 7) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.name && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Workflow : {run.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-dark-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{runs.length} run{runs.length > 1 ? 's' : ''} affichés</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-secondary py-1.5 px-4 text-sm"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { LayoutList, TerminalSquare,
|
||||
import { LayoutList, TerminalSquare, ShieldCheck,
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
@@ -15,6 +15,7 @@ import { LayoutList, TerminalSquare,
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'apps', label: 'Applications', icon: Box },
|
||||
{ id: 'ci', label: 'CI / Qualité', icon: ShieldCheck },
|
||||
{ id: 'deployments', label: 'Déploiements', icon: ScrollText },
|
||||
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
|
||||
{ id: 'docker', label: 'Docker', icon: Container },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, RefreshCw, Plus } from 'lucide-react';
|
||||
import AppCard from '../components/AppCard';
|
||||
import AddAppModal from '../components/AddAppModal';
|
||||
import { getApps, getGiteaCommits } from '../utils/api';
|
||||
import { sortAppsWithInfraFirst } from '../utils/appSorting';
|
||||
|
||||
export default function AppsPage({ apps, onRefresh }) {
|
||||
const [commitsMap, setCommitsMap] = useState({});
|
||||
@@ -26,6 +27,9 @@ export default function AppsPage({ apps, onRefresh }) {
|
||||
});
|
||||
}, [apps]);
|
||||
|
||||
// Les services d’infrastructure restent visibles en premier, sans modifier les données API.
|
||||
const sortedApps = sortAppsWithInfraFirst(apps);
|
||||
|
||||
const handleAddSuccess = () => {
|
||||
setShowAddModal(false);
|
||||
// Rafraîchir la liste après un délai pour laisser le temps au health check
|
||||
@@ -86,7 +90,7 @@ export default function AppsPage({ apps, onRefresh }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{apps.map((app) => (
|
||||
{sortedApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
|
||||
266
src/frontend/src/pages/CiPage.jsx
Normal file
266
src/frontend/src/pages/CiPage.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RefreshCw, CheckCircle, XCircle, Clock, TrendingUp, TrendingDown, Minus, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { getApps, getGiteaWorkflowSummary, getGiteaWorkflowRuns } from '../utils/api';
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds && seconds !== 0) return '—';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||||
}
|
||||
|
||||
function TrendIcon({ trend }) {
|
||||
if (trend === 'faster') return <TrendingDown className="w-4 h-4 text-emerald-400" />;
|
||||
if (trend === 'slower') return <TrendingUp className="w-4 h-4 text-amber-400" />;
|
||||
return <Minus className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
function CiBadge({ conclusion }) {
|
||||
if (conclusion === 'success') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Vert
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (conclusion === 'failure') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<XCircle className="w-3 h-3" /> Échec
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
|
||||
<Clock className="w-3 h-3" /> —
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CiPage({ apps }) {
|
||||
const [ciData, setCiData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
|
||||
const fetchCiData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Pour chaque app qui a un giteaRepo, récupérer le résumé CI et le dernier run
|
||||
const results = await Promise.all(
|
||||
(apps || [])
|
||||
.filter(a => a.giteaRepo || a.id)
|
||||
.map(async (app) => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
try {
|
||||
const [summaryRes, runsRes] = await Promise.all([
|
||||
getGiteaWorkflowSummary(owner, repo),
|
||||
getGiteaWorkflowRuns(owner, repo),
|
||||
]);
|
||||
const summary = summaryRes.data || {};
|
||||
const runs = runsRes.data || [];
|
||||
const lastRun = runs.length > 0 ? runs[0] : null;
|
||||
return {
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
ciRequired: true, // Toutes les apps ont ci.required:true maintenant
|
||||
lastRun,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
ciRequired: false,
|
||||
lastRun: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
setCiData(results);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement CI:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (apps && apps.length > 0) {
|
||||
fetchCiData();
|
||||
}
|
||||
}, [apps]);
|
||||
|
||||
// Stats globales
|
||||
const totalApps = ciData.length;
|
||||
const appsWithCi = ciData.filter(d => d.lastRun);
|
||||
const appsGreen = ciData.filter(d => d.lastRun?.conclusion === 'success');
|
||||
const appsFailed = ciData.filter(d => d.lastRun?.conclusion === 'failure');
|
||||
const globalSuccessRate = appsWithCi.length > 0
|
||||
? Math.round((appsGreen.length / appsWithCi.length) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Shield className="w-7 h-7 text-primary-400" />
|
||||
CI / Qualité
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Gouvernance de l'intégration continue — toutes les applications
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastRefresh && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Mis à jour : {lastRefresh.toLocaleTimeString('fr-FR')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchCiData}
|
||||
disabled={loading}
|
||||
className="btn-secondary py-2 px-4 text-sm flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs globaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Applications CI</span>
|
||||
<Shield className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white mt-2">{appsWithCi.length}<span className="text-lg text-gray-500">/{totalApps}</span></p>
|
||||
<p className="text-xs text-gray-500 mt-1">Avec workflow actif</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Taux de succès</span>
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<p className={`text-3xl font-bold mt-2 ${globalSuccessRate >= 80 ? 'text-emerald-400' : globalSuccessRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{globalSuccessRate}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{appsGreen.length} vertes / {appsFailed.length} en échec</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">CI verte</span>
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-emerald-400 mt-2">{appsGreen.length}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Déploiement autorisé</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">CI en échec</span>
|
||||
<XCircle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-400 mt-2">{appsFailed.length}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Déploiement bloqué</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau détaillé */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-dark-700">
|
||||
<h3 className="text-lg font-semibold text-white">Détail par application</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="p-8 flex items-center justify-center gap-3 text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Chargement des données CI...
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700 bg-dark-800/50">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase">Application</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">CI requise</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Dernier run</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Taux succès</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Durée médiane</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Tendance</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Garde-fou</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{ciData.map((item) => (
|
||||
<tr key={item.appId} className="hover:bg-dark-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-medium text-white">{item.appName}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{item.appId}</span>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.ciRequired ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-primary-500/15 text-primary-400 border border-primary-500/30">
|
||||
<Shield className="w-3 h-3" /> Oui
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<CiBadge conclusion={item.lastRun?.conclusion} />
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.summary?.successRate != null ? (
|
||||
<span className={`font-semibold ${item.summary.successRate >= 80 ? 'text-emerald-400' : item.summary.successRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{item.summary.successRate}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<span className="text-gray-300">
|
||||
{formatDuration(item.summary?.medianDurationSeconds)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TrendIcon trend={item.summary?.trend} />
|
||||
{item.summary?.changePercent != null && (
|
||||
<span className={`text-xs ${item.summary.changePercent <= -10 ? 'text-emerald-400' : item.summary.changePercent >= 10 ? 'text-amber-400' : 'text-gray-500'}`}>
|
||||
{item.summary.changePercent > 0 ? '+' : ''}{item.summary.changePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.lastRun?.conclusion === 'success' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Autorisé
|
||||
</span>
|
||||
) : item.lastRun?.conclusion === 'failure' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<AlertTriangle className="w-3 h-3" /> Bloqué
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getApps, getSystemInfo } from '../utils/api';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { sortAppsWithInfraFirst } from '../utils/appSorting';
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color, subtext }) {
|
||||
return (
|
||||
@@ -44,6 +45,7 @@ export default function DashboardPage({ apps }) {
|
||||
const onlineApps = apps.filter((a) => a.status === 'online').length;
|
||||
const offlineApps = apps.filter((a) => a.status === 'offline').length;
|
||||
const errorApps = apps.filter((a) => a.status === 'error').length;
|
||||
const sortedApps = sortAppsWithInfraFirst(apps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -147,7 +149,7 @@ export default function DashboardPage({ apps }) {
|
||||
<p className="text-gray-500 text-sm">Aucune application détectée</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{apps.map((app) => {
|
||||
{sortedApps.map((app) => {
|
||||
const isInfra = app.category === "INFRA" || ["portail-santinova", "manus-dashboard"].includes(app.id);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
|
||||
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
@@ -41,12 +43,8 @@ function formatDuration(seconds) {
|
||||
}
|
||||
|
||||
function CiStatus({ run }) {
|
||||
if (run.status !== 'completed') {
|
||||
return <span className="text-amber-300 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> En cours</span>;
|
||||
}
|
||||
if (run.conclusion === 'success') {
|
||||
return <span className="text-emerald-300 flex items-center gap-1"><CheckCircle2 className="w-3 h-3" /> Réussi</span>;
|
||||
}
|
||||
if (run.status !== 'completed') return <span className="text-amber-300 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> En cours</span>;
|
||||
if (run.conclusion === 'success') return <span className="text-emerald-300 flex items-center gap-1"><CheckCircle2 className="w-3 h-3" /> Réussi</span>;
|
||||
return <span className="text-red-300 flex items-center gap-1"><XCircle className="w-3 h-3" /> {run.conclusion || 'Échec'}</span>;
|
||||
}
|
||||
|
||||
@@ -58,6 +56,34 @@ export default function GiteaPage() {
|
||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
const [rotation, setRotation] = useState(null);
|
||||
const [rotationBusy, setRotationBusy] = useState(false);
|
||||
const [rotationError, setRotationError] = useState('');
|
||||
|
||||
const fetchRotation = async () => {
|
||||
try {
|
||||
const response = await getGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.');
|
||||
}
|
||||
};
|
||||
|
||||
const runRotationAction = async (action) => {
|
||||
setRotationBusy(true);
|
||||
setRotationError('');
|
||||
try {
|
||||
const response = action === 'prepare'
|
||||
? await prepareGiteaTokenRotation()
|
||||
: await approveGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Opération de rotation impossible.');
|
||||
if (error.response?.data?.status) setRotation(error.response.data.status);
|
||||
} finally {
|
||||
setRotationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setLoading(true);
|
||||
@@ -96,6 +122,7 @@ export default function GiteaPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
fetchRotation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -128,6 +155,31 @@ export default function GiteaPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 border border-primary-500/20 bg-primary-500/5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary-500/10 p-2"><KeyRound className="h-5 w-5 text-primary-300" /></div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Rotation contrôlée du jeton Gitea</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">{rotation?.message || 'Chargement du statut…'}</p>
|
||||
{rotation?.tokenName && <p className="mt-1 text-xs font-mono text-gray-500">{rotation.tokenName}</p>}
|
||||
{rotation?.updatedAt && <p className="mt-1 text-xs text-gray-500">Mis à jour : {formatDate(rotation.updatedAt)}</p>}
|
||||
{rotationError && <p className="mt-2 text-sm text-red-300">{rotationError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('prepare')} className="btn-secondary flex items-center gap-2 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${rotationBusy ? 'animate-spin' : ''}`} /> Préparer maintenant
|
||||
</button>
|
||||
{rotation?.state === 'pending_approval' && (
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('approve')} className="btn-primary flex items-center gap-2 disabled:opacity-50">
|
||||
<ShieldCheck className="h-4 w-4" /> Approuver le basculement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos List */}
|
||||
<div className="space-y-3">
|
||||
@@ -232,25 +284,13 @@ export default function GiteaPage() {
|
||||
<>
|
||||
{workflowSummary?.sampleSize > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3 text-xs">
|
||||
<div className="rounded bg-dark-700/70 p-2">
|
||||
<p className="text-gray-500">Succès</p>
|
||||
<p className="font-semibold text-emerald-300">{workflowSummary.successRate}%</p>
|
||||
</div>
|
||||
<div className="rounded bg-dark-700/70 p-2">
|
||||
<p className="text-gray-500">Médiane</p>
|
||||
<p className="font-semibold text-gray-200">{formatDuration(workflowSummary.medianDurationSeconds)}</p>
|
||||
</div>
|
||||
<div className="rounded bg-dark-700/70 p-2">
|
||||
<p className="text-gray-500">Tendance</p>
|
||||
<p className={`font-semibold flex items-center gap-1 ${workflowSummary.trend === 'faster' ? 'text-emerald-300' : workflowSummary.trend === 'slower' ? 'text-red-300' : 'text-gray-300'}`}>
|
||||
{workflowSummary.trend === 'faster' ? <TrendingDown className="w-3 h-3" /> : workflowSummary.trend === 'slower' ? <TrendingUp className="w-3 h-3" /> : <Minus className="w-3 h-3" />}
|
||||
{workflowSummary.changePercent === null ? 'N/A' : `${Math.abs(workflowSummary.changePercent)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className="text-gray-500">Succès</p><p className="font-semibold text-emerald-300">{workflowSummary.successRate}%</p></div>
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className="text-gray-500">Médiane</p><p className="font-semibold text-gray-200">{formatDuration(workflowSummary.medianDurationSeconds)}</p></div>
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className={`font-semibold flex items-center gap-1 ${workflowSummary.trend === 'faster' ? 'text-emerald-300' : workflowSummary.trend === 'slower' ? 'text-red-300' : 'text-gray-300'}`}>{workflowSummary.trend === 'faster' ? <TrendingDown className="w-3 h-3" /> : workflowSummary.trend === 'slower' ? <TrendingUp className="w-3 h-3" /> : <Minus className="w-3 h-3" />}{workflowSummary.changePercent === null ? 'N/A' : `${Math.abs(workflowSummary.changePercent)}%`}</p></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{workflowRuns.slice(0, 5).map((run) => (
|
||||
{workflowRuns.slice(0, 5).map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between gap-3 text-xs">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 truncate">{run.name}</p>
|
||||
@@ -261,7 +301,7 @@ export default function GiteaPage() {
|
||||
<span className="text-gray-500 flex items-center justify-end gap-1 mt-1"><Clock className="w-3 h-3" />{formatDuration(run.durationSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,7 @@ const APPS_CONFIG = [
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard',
|
||||
name: 'Dashboard Recette',
|
||||
repoName: 'manus-dashboard',
|
||||
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
||||
urlProd: 'https://dashboard.santinova-soft.org',
|
||||
@@ -107,9 +107,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
|
||||
if (!shaRec && !shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<Minus className="w-3 h-3" />
|
||||
Non déployé
|
||||
N/A
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -123,9 +123,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
}
|
||||
if (!shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Recette déployée, production vide">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
Non déployé en prod
|
||||
Recette en avance
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -175,19 +175,19 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
}
|
||||
|
||||
function VersionBadge({ version, loading }) {
|
||||
if (!version) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||
<Tag className="w-3 h-3" />
|
||||
Chargement...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||
<Tag className="w-3 h-3" />
|
||||
Chargement...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!version) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Non déployé
|
||||
N/A
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ export default function LoginPage({ onLogin }) {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
||||
<Server className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dashboard {window.location.hostname.includes('recette') ? 'Recette' : 'Production'}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard Production</h1>
|
||||
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const createApp = (formData) =>
|
||||
// Deployments
|
||||
export const getDeployments = (appId) =>
|
||||
api.get('/deployments', { params: { appId } });
|
||||
export const getCiAlerts = () => api.get('/ci-alerts');
|
||||
|
||||
// Gitea
|
||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||
@@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
||||
export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation');
|
||||
export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare');
|
||||
export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve');
|
||||
|
||||
// Docker
|
||||
export const getContainers = () => api.get('/docker/containers');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user