diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b12d20 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +README.md +*.log +frontend/node_modules +backend/node_modules diff --git a/.gitignore b/.gitignore index aa0926a..4274b51 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .env *.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82c8d4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend package files +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ +RUN npm install + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend +RUN npm run build + +# Stage 2: Production +FROM node:20-alpine + +WORKDIR /app + +# Install docker CLI for docker compose commands +RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh-client + +# Configure git global identity to avoid "Author identity unknown" errors +RUN git config --global user.email "admin@santinova-soft.org" && \ + git config --global user.name "Manus Dashboard" + +# Copy backend +COPY backend/package.json ./backend/ +WORKDIR /app/backend +RUN npm install --production + +COPY backend/ ./ + +# Copy built frontend +WORKDIR /app +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +WORKDIR /app/backend + +EXPOSE 3001 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cf68ec --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Manus Dashboard + +Tableau de bord de gestion des applications Manus, déployé sur le serveur de recette. + +## Fonctionnalités + +- Liste des applications déployées avec état en temps réel +- Health checks HTTP périodiques avec indicateurs visuels +- Redéploiement en un clic (docker compose build + up) +- Logs de déploiement et logs conteneurs +- Intégration Gitea (commits, branches, dépôts) +- Authentification par login/mot de passe +- Interface moderne avec TailwindCSS + +## Stack technique + +- **Backend** : Node.js / Express +- **Frontend** : React / TailwindCSS / Vite +- **Conteneurisation** : Docker / Docker Compose +- **Reverse Proxy** : Traefik avec SSL Let's Encrypt + +## Déploiement + +```bash +cd /opt/manus-deploy/apps/manus-dashboard +docker compose build --no-cache && docker compose up -d +``` + +## Accès + +- URL : https://dashboard.santinova-soft.org +- Login : adminItinova / Itinova69! diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3dce267 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "manus-dashboard-backend", + "version": "1.0.0", + "description": "Backend API for Manus Dashboard", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "axios": "^1.7.0", + "dockerode": "^4.0.2", + "ws": "^8.18.0", + "node-cron": "^3.0.3", + "express-rate-limit": "^7.4.0", + "cookie-parser": "^1.4.6", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "adm-zip": "^0.5.10", + "ssh2": "^1.16.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/backend/src/app-creator.js b/backend/src/app-creator.js new file mode 100644 index 0000000..0ec2901 --- /dev/null +++ b/backend/src/app-creator.js @@ -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, +}; diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..bf27443 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,61 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const config = require('./config'); + +// Hash du mot de passe au démarrage +let passwordHash = null; + +async function initAuth() { + passwordHash = await bcrypt.hash(config.auth.password, 10); +} + +async function authenticate(username, password) { + if (username !== config.auth.username) { + return null; + } + // Comparer directement car on stocke le mot de passe en clair dans la config + if (password !== config.auth.password) { + return null; + } + const token = jwt.sign( + { username, role: 'admin' }, + config.jwtSecret, + { expiresIn: config.jwtExpiry } + ); + return token; +} + +function verifyToken(token) { + try { + return jwt.verify(token, config.jwtSecret); + } catch (err) { + return null; + } +} + +function authMiddleware(req, res, next) { + // Check Authorization header + let token = null; + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + // Check cookie + if (!token && req.cookies) { + token = req.cookies.dashboard_token; + } + + if (!token) { + return res.status(401).json({ error: 'Non authentifié' }); + } + + const decoded = verifyToken(token); + if (!decoded) { + return res.status(401).json({ error: 'Token invalide ou expiré' }); + } + + req.user = decoded; + next(); +} + +module.exports = { initAuth, authenticate, verifyToken, authMiddleware }; diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..a885ce3 --- /dev/null +++ b/backend/src/config.js @@ -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 V2', + 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', + }, + ], +}; diff --git a/backend/src/docker.js b/backend/src/docker.js new file mode 100644 index 0000000..7122a79 --- /dev/null +++ b/backend/src/docker.js @@ -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, +}; diff --git a/backend/src/gitea.js b/backend/src/gitea.js new file mode 100644 index 0000000..84f1d1b --- /dev/null +++ b/backend/src/gitea.js @@ -0,0 +1,119 @@ +const axios = require('axios'); +const https = require('https'); +const config = require('./config'); + +// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire) +// Utiliser le token API si disponible, sinon auth basique +const giteaClientHeaders = config.gitea.token + ? { 'Authorization': `token ${config.gitea.token}` } + : {}; + +const giteaClient = axios.create({ + baseURL: `${config.gitea.url}/api/v1`, + ...(config.gitea.token + ? { headers: giteaClientHeaders } + : { auth: { username: config.gitea.username, password: config.gitea.password } } + ), + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + timeout: 10000, +}); + +/** + * Récupérer les informations d'un dépôt + */ +async function getRepo(owner, repo) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}`); + return response.data; + } catch (err) { + console.error(`Erreur Gitea getRepo ${owner}/${repo}:`, err.message); + return null; + } +} + +/** + * Récupérer les derniers commits d'un dépôt + */ +async function getCommits(owner, repo, limit = 10) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/commits`, { + params: { limit, page: 1 }, + }); + return response.data.map((c) => ({ + sha: c.sha, + shortSha: c.sha.substring(0, 7), + message: c.commit.message, + author: c.commit.author.name, + date: c.commit.author.date, + url: c.html_url, + })); + } catch (err) { + console.error(`Erreur Gitea getCommits ${owner}/${repo}:`, err.message); + return []; + } +} + +/** + * Récupérer les branches d'un dépôt + */ +async function getBranches(owner, repo) { + try { + const response = await giteaClient.get(`/repos/${owner}/${repo}/branches`); + return response.data.map((b) => ({ + name: b.name, + commit: b.commit.id.substring(0, 7), + commitMessage: b.commit.message, + })); + } catch (err) { + console.error(`Erreur Gitea getBranches ${owner}/${repo}:`, err.message); + return []; + } +} + +/** + * Récupérer tous les dépôts (avec retry en cas d'erreur DNS) + */ +async function listRepos(retries = 3) { + for (let i = 0; i < retries; i++) { + try { + const response = await giteaClient.get('/repos/search', { + params: { limit: 50 }, + }); + return response.data.data || []; + } catch (err) { + console.error(`Erreur Gitea listRepos (tentative ${i + 1}/${retries}):`, err.message); + if (i < retries - 1) { + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + return []; +} + +/** + * Créer un nouveau dépôt + */ +async function createRepo(name, description, isPrivate = true) { + try { + const response = await giteaClient.post('/user/repos', { + name, + description, + private: isPrivate, + auto_init: true, + default_branch: 'main', + }); + return response.data; + } catch (err) { + console.error(`Erreur Gitea createRepo ${name}:`, err.message); + throw err; + } +} + +module.exports = { + getRepo, + getCommits, + getBranches, + listRepos, + createRepo, + giteaClient, +}; diff --git a/backend/src/healthcheck.js b/backend/src/healthcheck.js new file mode 100644 index 0000000..45fceaa --- /dev/null +++ b/backend/src/healthcheck.js @@ -0,0 +1,188 @@ +const axios = require('axios'); +const https = require('https'); +const config = require('./config'); +const { getContainerInfo } = require('./docker'); + +// Store pour les statuts des apps +const appStatuses = new Map(); + +// Store pour les logs de déploiement +const deploymentLogs = []; +const MAX_DEPLOYMENT_LOGS = 50; + +// Store pour les commits Gitea +const giteaCommits = new Map(); + +// Agent HTTPS qui ignore les certificats auto-signés +const httpsAgent = new https.Agent({ rejectUnauthorized: false }); + +// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed" +// (ils ont été interrompus lors du dernier redémarrage du Dashboard) +function cleanupOrphanedDeployments() { + const orphans = deploymentLogs.filter((l) => l.status === 'running'); + if (orphans.length > 0) { + console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`); + orphans.forEach((l) => { + l.status = 'failed'; + l.message = 'Déploiement interrompu (redémarrage du Dashboard)'; + l.endedAt = new Date().toISOString(); + }); + } +} + +/** + * Vérifier la santé d'une URL + */ +async function checkUrl(url) { + try { + const start = Date.now(); + const response = await axios.get(url, { + timeout: 10000, + httpsAgent, + validateStatus: (status) => status < 500, + }); + const responseTime = Date.now() - start; + return { + online: true, + statusCode: response.status, + responseTime, + }; + } catch (err) { + return { + online: false, + statusCode: null, + responseTime: null, + error: err.message, + }; + } +} + +/** + * Vérifier la santé d'une application + */ +async function checkApp(appConfig) { + const result = { + id: appConfig.id, + name: appConfig.name, + description: appConfig.description, + urls: appConfig.urls, + containerName: appConfig.containerName, + lastCheck: new Date().toISOString(), + status: 'unknown', + container: null, + health: { + recette: null, + prod: null, + }, + }; + + // Vérifier le conteneur Docker + const containerInfo = await getContainerInfo(appConfig.containerName); + result.container = containerInfo; + + // Health check recette + if (appConfig.urls.recette) { + result.health.recette = await checkUrl(appConfig.urls.recette); + } + + // Health check prod + if (appConfig.urls.prod) { + result.health.prod = await checkUrl(appConfig.urls.prod); + } + + // Déterminer le statut global + if (!containerInfo || !containerInfo.running) { + result.status = 'offline'; + } else if (result.health.recette && result.health.recette.online) { + result.status = 'online'; + } else if (containerInfo.running) { + result.status = 'error'; + } else { + result.status = 'offline'; + } + + // Stocker le résultat + appStatuses.set(appConfig.id, result); + + return result; +} + +/** + * Lancer les health checks pour toutes les apps + */ +async function checkAllApps() { + const results = []; + for (const app of config.apps) { + const result = await checkApp(app); + results.push(result); + } + return results; +} + +/** + * Ajouter un log de déploiement (retourne l'entrée créée avec son id) + */ +function addDeploymentLog(appId, log) { + const entry = { + id: Date.now().toString(), + appId, + timestamp: new Date().toISOString(), + ...log, + }; + deploymentLogs.unshift(entry); + if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) { + deploymentLogs.pop(); + } + return entry; +} + +/** + * Mettre à jour un log de déploiement existant par son id + * Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée + */ +function updateDeploymentLog(entryId, updates) { + const entry = deploymentLogs.find((l) => l.id === entryId); + if (entry) { + Object.assign(entry, updates, { updatedAt: new Date().toISOString() }); + return entry; + } + return null; +} + +/** + * Récupérer les logs de déploiement + */ +function getDeploymentLogs(appId = null) { + if (appId) { + return deploymentLogs.filter((l) => l.appId === appId); + } + return deploymentLogs; +} + +/** + * Récupérer le statut d'une app + */ +function getAppStatus(appId) { + return appStatuses.get(appId) || null; +} + +/** + * Récupérer tous les statuts + */ +function getAllStatuses() { + return Array.from(appStatuses.values()); +} + +module.exports = { + checkUrl, + checkApp, + checkAllApps, + addDeploymentLog, + updateDeploymentLog, + getDeploymentLogs, + getAppStatus, + getAllStatuses, + cleanupOrphanedDeployments, + appStatuses, + giteaCommits, +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..b8c951e --- /dev/null +++ b/backend/src/index.js @@ -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); +}); diff --git a/backend/src/routes.js b/backend/src/routes.js new file mode 100644 index 0000000..fbdd2e4 --- /dev/null +++ b/backend/src/routes.js @@ -0,0 +1,601 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { authenticate, authMiddleware } = require('./auth'); +const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker'); +const { getRepo, getCommits, getBranches, listRepos } = require('./gitea'); +const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck'); +const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); +const config = require('./config'); + +// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent) +cleanupOrphanedDeployments(); + +const router = express.Router(); + +// Configuration multer pour l'upload de fichiers ZIP +const uploadDir = '/tmp/dashboard-uploads'; +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const upload = multer({ + dest: uploadDir, + limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max + fileFilter: (req, file, cb) => { + if (file.mimetype === 'application/zip' || + file.mimetype === 'application/x-zip-compressed' || + file.mimetype === 'application/octet-stream' || + file.originalname.endsWith('.zip')) { + cb(null, true); + } else { + cb(new Error('Seuls les fichiers ZIP sont acceptés'), false); + } + }, +}); + +// ============ AUTH ROUTES ============ + +router.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); + } + const token = await authenticate(username, password); + if (!token) { + return res.status(401).json({ error: 'Identifiants invalides' }); + } + // Set cookie + res.cookie('dashboard_token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 24 * 60 * 60 * 1000, // 24h + }); + res.json({ token, username }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/auth/logout', (req, res) => { + res.clearCookie('dashboard_token'); + res.json({ message: 'Déconnecté' }); +}); + +router.get('/auth/me', authMiddleware, (req, res) => { + res.json({ username: req.user.username, role: req.user.role }); +}); + +// ============ APPS ROUTES ============ + +// Liste des applications avec leur statut +router.get('/apps', authMiddleware, async (req, res) => { + try { + const statuses = getAllStatuses(); + if (statuses.length === 0) { + // Première requête, lancer un check + const results = await checkAllApps(); + return res.json(results); + } + res.json(statuses); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Détail d'une application +router.get('/apps/:id', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + + const status = getAppStatus(appConfig.id); + const containerInfo = await getContainerInfo(appConfig.containerName); + + // Récupérer les infos Gitea + let giteaInfo = null; + let commits = []; + try { + giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo); + commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10); + } catch (e) { + // Gitea peut ne pas être disponible + } + + res.json({ + ...status, + container: containerInfo, + gitea: giteaInfo + ? { + fullName: giteaInfo.full_name, + description: giteaInfo.description, + defaultBranch: giteaInfo.default_branch, + updatedAt: giteaInfo.updated_at, + cloneUrl: giteaInfo.clone_url, + htmlUrl: giteaInfo.html_url, + } + : null, + commits, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Forcer un health check +router.post('/apps/:id/check', authMiddleware, async (req, res) => { + try { + const results = await checkAllApps(); + const result = results.find((r) => r.id === req.params.id); + res.json(result || { error: 'Application non trouvée' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Verrou anti-concurrence par application +const deployLocks = new Map(); +const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max + +// Nettoyage automatique des verrous bloqués toutes les minutes +setInterval(() => { + const now = Date.now(); + for (const [appId, lockTime] of deployLocks.entries()) { + if (now - lockTime > DEPLOY_TIMEOUT_MS) { + deployLocks.delete(appId); + const logs = getDeploymentLogs(appId); + logs.filter(l => l.status === 'running').forEach(l => { + l.status = 'failed'; + l.message = 'Déploiement interrompu automatiquement (timeout 10 min)'; + }); + } + } +}, 60 * 1000); + +// Redéployer une application +router.post('/apps/:id/deploy', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + + // Vérifier si un déploiement est déjà en cours pour cette app + if (deployLocks.has(appConfig.id)) { + const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000); + return res.status(409).json({ + error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`, + status: 'running' + }); + } + + // Poser le verrou + deployLocks.set(appConfig.id, Date.now()); + + // Ajouter un log de début et récupérer son id pour le mettre à jour plus tard + const deployEntry = addDeploymentLog(appConfig.id, { + status: 'running', + message: `Déploiement de ${appConfig.name} en cours...`, + logs: '', + user: req.user.username, + }); + + // Lancer le redéploiement en arrière-plan + res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' }); + + // Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed + const timeoutHandle = setTimeout(() => { + if (deployLocks.has(appConfig.id)) { + deployLocks.delete(appConfig.id); + updateDeploymentLog(deployEntry.id, { + status: 'failed', + message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`, + }); + } + }, DEPLOY_TIMEOUT_MS); + + try { + // Exécuter le redéploiement + const result = await redeployApp(appConfig); + clearTimeout(timeoutHandle); + + // Mettre à jour l'entrée existante (pas de doublon "running" + "success") + updateDeploymentLog(deployEntry.id, { + status: result.success ? 'success' : 'failed', + message: result.success + ? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s` + : `Redéploiement de ${appConfig.name} échoué`, + logs: result.logs, + duration: result.duration, + }); + + // Relancer un health check après le déploiement + setTimeout(() => checkAllApps(), 5000); + } finally { + // Toujours libérer le verrou + deployLocks.delete(appConfig.id); + } + } catch (err) { + deployLocks.delete(req.params.id); + addDeploymentLog(req.params.id, { + status: 'failed', + message: `Erreur: ${err.message}`, + logs: '', + user: req.user.username, + }); + } +}); + +// Logs d'un conteneur +router.get('/apps/:id/logs', authMiddleware, async (req, res) => { + try { + const appConfig = config.apps.find((a) => a.id === req.params.id); + if (!appConfig) { + return res.status(404).json({ error: 'Application non trouvée' }); + } + const tail = parseInt(req.query.tail) || 100; + const logs = await getContainerLogs(appConfig.containerName, tail); + res.json({ logs }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ CREATE APP ROUTE ============ + +// Récupérer les stacks disponibles +router.get('/apps-config/stacks', authMiddleware, (req, res) => { + res.json(getAvailableStacks()); +}); + +// Créer une nouvelle application +router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => { + try { + const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body; + + // Validation + if (!name || !subdomain || !stack || !sourceType) { + return res.status(400).json({ + error: 'Champs requis manquants: name, subdomain, stack, sourceType', + }); + } + + // Vérifier que le subdomain est valide + const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!subdomainRegex.test(subdomain)) { + return res.status(400).json({ + error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets', + }); + } + + // Vérifier que l'app n'existe pas déjà + const existingApp = config.apps.find((a) => a.id === subdomain); + if (existingApp) { + return res.status(409).json({ + error: `Une application avec l'identifiant "${subdomain}" existe déjà`, + }); + } + + // Vérifier le fichier ZIP si sourceType === 'zip' + if (sourceType === 'zip' && !req.file) { + return res.status(400).json({ + error: 'Un fichier ZIP est requis pour le type de source "zip"', + }); + } + + // Vérifier l'URL Gitea si sourceType === 'gitea' + if (sourceType === 'gitea' && !giteaRepoUrl) { + return res.status(400).json({ + error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"', + }); + } + + // Log de début de création et récupérer l'id pour mise à jour ultérieure + const logLines = []; + const createEntry = addDeploymentLog(subdomain, { + status: 'running', + message: `Création de l'application ${name} en cours...`, + logs: '', + user: req.user.username, + }); + + // Répondre immédiatement + res.json({ + message: `Création de l'application ${name} lancée`, + status: 'running', + appId: subdomain, + }); + + // Lancer la création en arrière-plan + try { + const result = await createApplication( + { + name, + description, + subdomain, + stack, + port: port ? parseInt(port) : null, + needsDb: needsDb === 'true' || needsDb === true, + dbType: dbType || 'mysql', + sourceType, + giteaRepoUrl, + zipFilePath: req.file ? req.file.path : null, + }, + (line) => logLines.push(line) + ); + + updateDeploymentLog(createEntry.id, { + status: 'success', + message: `Application ${name} créée et déployée avec succès`, + logs: logLines.join('\n'), + }); + + // Relancer un health check + setTimeout(() => checkAllApps(), 10000); + } catch (createErr) { + updateDeploymentLog(createEntry.id, { + status: 'failed', + message: `Création de ${name} échouée: ${createErr.message}`, + logs: logLines.join('\n'), + }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ DEPLOYMENT LOGS ============ + +router.get('/deployments', authMiddleware, (req, res) => { + const appId = req.query.appId || null; + res.json(getDeploymentLogs(appId)); +}); + +// ============ GITEA ROUTES ============ + +router.get('/gitea/repos', authMiddleware, async (req, res) => { + try { + const repos = await listRepos(); + res.json( + repos.map((r) => ({ + id: r.id, + name: r.name, + fullName: r.full_name, + description: r.description, + private: r.private, + htmlUrl: r.html_url, + cloneUrl: r.clone_url, + updatedAt: r.updated_at, + language: r.language, + size: r.size, + })) + ); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => { + try { + const commits = await getCommits(req.params.owner, req.params.repo, 20); + res.json(commits); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => { + try { + const branches = await getBranches(req.params.owner, req.params.repo); + res.json(branches); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ PUBLIC STATUS ROUTE (sans authentification) ============ +// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées +router.get('/public/status', async (req, res) => { + try { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + const statuses = getAllStatuses(); + const publicStatus = statuses.map((app) => ({ + id: app.id, + name: app.name, + status: app.status, // 'online' | 'offline' | 'error' | 'unknown' + containerRunning: app.container ? app.container.running : false, + })); + res.json(publicStatus); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ PUBLIC APPS ROUTE (sans authentification) ============ +// Utilisé par le portail applicatif pour construire les tuiles dynamiquement +// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles +router.get('/public/apps', (req, res) => { + try { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + // getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué + // On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse + const statuses = getAllStatuses(); + const publicApps = config.apps + .filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard) + .map((a) => { + const status = statuses.find((s) => s.id === a.id); + return { + id: a.id, + name: a.name, + category: a.category || 'SANTINOVA', + image: a.image || ('images/' + a.id + '.png'), + urls: a.urls || {}, + status: status ? status.status : 'unknown', + containerRunning: status && status.container ? status.container.running : false, + }; + }); + res.json(publicApps); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ DOCKER ROUTES ============ + +router.get('/docker/containers', authMiddleware, async (req, res) => { + try { + const containers = await listContainers(); + res.json(containers); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============ SYSTEM INFO ============ + +router.get('/system/info', authMiddleware, async (req, res) => { + try { + const containers = await listContainers(); + res.json({ + totalApps: config.apps.length, + totalContainers: containers.length, + runningContainers: containers.filter((c) => c.state === 'running').length, + giteaUrl: config.gitea.url, + serverTime: new Date().toISOString(), + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + + +// ============ CONTAINER CONTROL (start/stop/restart) ============ +router.post('/apps/:id/start', authMiddleware, async (req, res) => { + try { + const app = config.apps.find((a) => a.id === req.params.id); + if (!app) return res.status(404).json({ error: 'Application non trouvée' }); + const containerName = app.containerName || app.id; + const result = await startContainer(containerName, 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 }); + } +}); +; diff --git a/backend/src/ssh.js b/backend/src/ssh.js new file mode 100644 index 0000000..34a3faf --- /dev/null +++ b/backend/src/ssh.js @@ -0,0 +1,179 @@ +/** + * Module SSH Terminal - Gestion des connexions SSH via WebSocket + * Utilise la bibliothèque ssh2 pour établir des connexions SSH + */ +const { Client } = require('ssh2'); +const WebSocket = require('ws'); +const jwt = require('jsonwebtoken'); +const config = require('./config'); + +/** + * Initialise le serveur WebSocket SSH sur /ws-ssh + * @param {http.Server} server - Le serveur HTTP Express + */ +function initSSHWebSocket(server) { + const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' }); + + wssSsh.on('connection', (ws, req) => { + // Vérifier l'authentification via le token dans l'URL + const url = new URL(req.url, 'http://localhost'); + const token = url.searchParams.get('token'); + + if (!token) { + ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' })); + ws.close(); + return; + } + + try { + jwt.verify(token, config.jwtSecret); + } catch (err) { + ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' })); + ws.close(); + return; + } + + console.log('Nouvelle connexion WebSocket SSH'); + + let sshClient = null; + let sshStream = null; + let connected = false; + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + + switch (msg.type) { + case 'connect': + // Établir la connexion SSH + handleConnect(ws, msg, (client, stream) => { + sshClient = client; + sshStream = stream; + connected = true; + }); + break; + + case 'input': + // Envoyer des données au terminal SSH + if (sshStream && connected) { + sshStream.write(msg.data); + } + break; + + case 'resize': + // Redimensionner le terminal + if (sshStream && connected) { + sshStream.setWindow(msg.rows, msg.cols, 0, 0); + } + break; + + case 'disconnect': + // Fermer la connexion SSH + if (sshClient) { + sshClient.end(); + } + break; + + default: + console.warn('Type de message SSH inconnu:', msg.type); + } + } catch (err) { + console.error('Erreur parsing message SSH:', err.message); + } + }); + + ws.on('close', () => { + console.log('Connexion WebSocket SSH fermée'); + if (sshClient) { + sshClient.end(); + } + }); + + ws.on('error', (err) => { + console.error('Erreur WebSocket SSH:', err.message); + if (sshClient) { + sshClient.end(); + } + }); + }); + + console.log('Serveur WebSocket SSH initialisé sur /ws-ssh'); + return wssSsh; +} + +/** + * Gère la connexion SSH + */ +function handleConnect(ws, msg, onConnected) { + const { host, port, username, password, privateKey } = msg; + + if (!host || !username) { + ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' })); + return; + } + + const sshClient = new Client(); + + const connConfig = { + host: host, + port: port || 22, + username: username, + readyTimeout: 15000, + keepaliveInterval: 10000, + }; + + if (privateKey) { + connConfig.privateKey = privateKey; + } else if (password) { + connConfig.password = password; + } else { + ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' })); + return; + } + + ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` })); + + sshClient.on('ready', () => { + ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` })); + + sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => { + if (err) { + ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` })); + sshClient.end(); + return; + } + + onConnected(sshClient, stream); + + // Transmettre les données du terminal SSH vers le WebSocket + stream.on('data', (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') })); + } + }); + + stream.stderr.on('data', (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') })); + } + }); + + stream.on('close', () => { + ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' })); + sshClient.end(); + }); + }); + }); + + sshClient.on('error', (err) => { + ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` })); + }); + + sshClient.on('end', () => { + ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' })); + }); + + sshClient.connect(connConfig); +} + +module.exports = { initSSHWebSocket }; diff --git a/backend/src/webhook.js b/backend/src/webhook.js new file mode 100644 index 0000000..48e7def --- /dev/null +++ b/backend/src/webhook.js @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index ad1bfdf..9b5bd8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,45 @@ services: dashboard: build: - context: ./src + context: . dockerfile: Dockerfile container_name: manus-dashboard restart: unless-stopped - privileged: true - env_file: - - .env environment: - NODE_ENV=production - PORT=3001 - - JWT_SECRET=${DASHBOARD_JWT_SECRET} - - ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} - - ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} - - GITEA_URL=${GITEA_URL} - - GITEA_USERNAME=${GITEA_USERNAME} - - GITEA_PASSWORD=${GITEA_PASSWORD} - - WEBHOOK_SECRET=${WEBHOOK_SECRET} - - APPS_BASE_PATH=${APPS_BASE_PATH} - - INFRA_BASE_PATH=${INFRA_BASE_PATH} - - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL} + - JWT_SECRET=manus-dashboard-jwt-secret-2026-recette + - ADMIN_USERNAME=adminItinova + - ADMIN_PASSWORD=Itinova69! + - GITEA_URL=https://git.recette.santinova-soft.org + - GITEA_USERNAME=manus-admin + - GITEA_PASSWORD=ManusGitea2026! + - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427 + - GITEA_RECETTE_URL=http://gitea:3000 + - GITEA_PASSWORD_PROD=ManusGitea2026! + - APPS_BASE_PATH=/opt/manus-deploy/apps + - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure + - HEALTH_CHECK_INTERVAL=30000 volumes: - /var/run/docker.sock:/var/run/docker.sock - /opt/manus-deploy:/opt/manus-deploy + extra_hosts: + - "git.santinova-soft.org:180.149.196.138" networks: - web labels: - "traefik.enable=true" + # Route HTTPS via dashboard.recette.santinova-soft.org - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc" - "traefik.http.routers.manus-dashboard.priority=100" + # Service - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.docker.network=web" + networks: web: external: true diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b7f321 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Dashboard Recette - Gestion des Applications + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9b377d1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3012 @@ +{ + "name": "manus-dashboard-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "manus-dashboard-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.0", + "lucide-react": "^0.441.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.441.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz", + "integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c395304 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "manus-dashboard-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "axios": "^1.7.0", + "lucide-react": "^0.441.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "vite": "^5.4.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..15947d7 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/frontend/public/infra_schema_v2.png b/frontend/public/infra_schema_v2.png new file mode 100644 index 0000000..43fb240 Binary files /dev/null and b/frontend/public/infra_schema_v2.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..dcbd001 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import LoginPage from './pages/LoginPage'; +import DashboardPage from './pages/DashboardPage'; +import AppsPage from './pages/AppsPage'; +import MonitoringPage from './pages/MonitoringPage'; +import DeploymentsPage from './pages/DeploymentsPage'; +import GiteaPage from './pages/GiteaPage'; +import DockerPage from './pages/DockerPage'; +import DocumentationPage from './pages/DocumentationPage'; +import InventairePage from './pages/InventairePage'; +import TerminalPage from './pages/TerminalPage'; +import Sidebar from './components/Sidebar'; +import useWebSocket from './hooks/useWebSocket'; +import { getMe, getApps, logout as apiLogout } from './utils/api'; +export default function App() { + const [authenticated, setAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState('dashboard'); + const [apps, setApps] = useState([]); + // Vérifier l'authentification au chargement + useEffect(() => { + const token = localStorage.getItem('dashboard_token'); + if (token) { + getMe() + .then(() => { + setAuthenticated(true); + fetchApps(); + }) + .catch(() => { + localStorage.removeItem('dashboard_token'); + setAuthenticated(false); + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + const fetchApps = async () => { + try { + const res = await getApps(); + setApps(res.data); + } catch (err) { + console.error('Erreur chargement apps:', err); + } + }; + // WebSocket handler + const handleWsMessage = useCallback((data) => { + if (data.type === 'health_update') { + setApps(data.data); + } + }, []); + const { connected: wsConnected } = useWebSocket( + authenticated ? handleWsMessage : null + ); + const handleLogin = (data) => { + setAuthenticated(true); + fetchApps(); + }; + const handleLogout = async () => { + try { + await apiLogout(); + } catch (e) { + // ignore + } + localStorage.removeItem('dashboard_token'); + setAuthenticated(false); + setApps([]); + }; + if (loading) { + return ( +
+
+ + + + + Chargement... +
+
+ ); + } + if (!authenticated) { + return ; + } + const renderPage = () => { + switch (currentPage) { + case 'dashboard': + return ; + case 'apps': + return ; + case 'deployments': + return ; + case 'gitea': + return ; + case 'docker': + return ; + case 'monitoring': + return ; + case 'documentation': + return ; + case 'inventaire': + return ; + case 'terminal': + return ; + default: + return ; + } + }; + return ( +
+ +
{renderPage()}
+
+ ); +} diff --git a/frontend/src/components/AddAppModal.jsx b/frontend/src/components/AddAppModal.jsx new file mode 100644 index 0000000..9c17ff0 --- /dev/null +++ b/frontend/src/components/AddAppModal.jsx @@ -0,0 +1,679 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Upload, + GitBranch, + Server, + Database, + Globe, + Package, + FileText, + CheckCircle, + AlertCircle, + Loader2, + ChevronDown, +} from 'lucide-react'; +import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api'; + +const STEPS = [ + { id: 1, title: 'Informations', icon: FileText }, + { id: 2, title: 'Source', icon: GitBranch }, + { id: 3, title: 'Stack & Options', icon: Server }, + { id: 4, title: 'Confirmation', icon: CheckCircle }, +]; + +export default function AddAppModal({ isOpen, onClose, onSuccess }) { + const [currentStep, setCurrentStep] = useState(1); + const [stacks, setStacks] = useState([]); + const [giteaRepos, setGiteaRepos] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState(null); + const [errors, setErrors] = useState({}); + + const [formData, setFormData] = useState({ + name: '', + description: '', + subdomain: '', + sourceType: 'zip', + zipFile: null, + giteaRepoUrl: '', + stack: 'nodejs', + port: '', + needsDb: false, + dbType: 'mysql', + }); + + useEffect(() => { + if (isOpen) { + getAvailableStacks() + .then((res) => setStacks(res.data)) + .catch(console.error); + getGiteaRepos() + .then((res) => setGiteaRepos(res.data)) + .catch(console.error); + // Reset + setCurrentStep(1); + setSubmitResult(null); + setErrors({}); + setFormData({ + name: '', + description: '', + subdomain: '', + sourceType: 'zip', + zipFile: null, + giteaRepoUrl: '', + stack: 'nodejs', + port: '', + needsDb: false, + dbType: 'mysql', + }); + } + }, [isOpen]); + + if (!isOpen) return null; + + const updateField = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + setErrors((prev) => ({ ...prev, [field]: null })); + }; + + const autoSubdomain = (name) => { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + }; + + const handleNameChange = (value) => { + updateField('name', value); + if (!formData.subdomain || formData.subdomain === autoSubdomain(formData.name)) { + updateField('subdomain', autoSubdomain(value)); + } + }; + + const validateStep = (step) => { + const newErrors = {}; + if (step === 1) { + if (!formData.name.trim()) newErrors.name = 'Le nom est requis'; + if (!formData.subdomain.trim()) newErrors.subdomain = 'Le sous-domaine est requis'; + else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(formData.subdomain)) { + newErrors.subdomain = 'Lettres minuscules, chiffres et tirets uniquement'; + } + } + if (step === 2) { + if (formData.sourceType === 'zip' && !formData.zipFile) { + newErrors.zipFile = 'Un fichier ZIP est requis'; + } + if (formData.sourceType === 'gitea' && !formData.giteaRepoUrl) { + newErrors.giteaRepoUrl = 'Sélectionnez un dépôt Gitea'; + } + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const nextStep = () => { + if (validateStep(currentStep)) { + setCurrentStep((prev) => Math.min(prev + 1, 4)); + } + }; + + const prevStep = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + setSubmitResult(null); + + try { + const fd = new FormData(); + fd.append('name', formData.name); + fd.append('description', formData.description); + fd.append('subdomain', formData.subdomain); + fd.append('stack', formData.stack); + fd.append('sourceType', formData.sourceType); + fd.append('needsDb', formData.needsDb.toString()); + fd.append('dbType', formData.dbType); + if (formData.port) fd.append('port', formData.port); + + if (formData.sourceType === 'zip' && formData.zipFile) { + fd.append('zipFile', formData.zipFile); + } + if (formData.sourceType === 'gitea') { + fd.append('giteaRepoUrl', formData.giteaRepoUrl); + } + + const res = await createApp(fd); + setSubmitResult({ success: true, data: res.data }); + + // Rafraîchir après un délai + setTimeout(() => { + if (onSuccess) onSuccess(); + }, 3000); + } catch (err) { + setSubmitResult({ + success: false, + error: err.response?.data?.error || err.message, + }); + } finally { + setIsSubmitting(false); + } + }; + + const selectedStack = stacks.find((s) => s.id === formData.stack); + + return ( +
+ {/* Overlay */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ + Ajouter une application +

+

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

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

{errors.name}

+ )} +
+ +
+ +