diff --git a/APP_MANIFEST.md b/APP_MANIFEST.md index ce78df2..e0c824a 100644 --- a/APP_MANIFEST.md +++ b/APP_MANIFEST.md @@ -19,6 +19,15 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac "giteaRepo": "itinova-contacts", "giteaOwner": "manus-admin", "image": "images/itinova-contacts.png", + "ai": { + "engines": [ + { + "provider": "Google Gemini", + "model": "gemini-2.5-flash", + "role": "Assistant conversationnel" + } + ] + }, "urls": { "recette": "https://contacts.recette.santinova-soft.org", "prod": "https://contacts.santinova-soft.org" @@ -35,11 +44,12 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac | `giteaRepo` | Dépôt source | Sert au webhook CI/CD et à l’inventaire. | | `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. | | `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. | +| `ai.engines` | Moteurs IA déclarés | Tableau optionnel de métadonnées publiques : `provider`, `model` et `role` optionnel. Ne jamais y mettre une clé, un jeton ni un endpoint privé. | | `urls.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. | | `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. | ## Règles de déploiement -Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. +Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. Lorsque l’application utilise un moteur IA, le même commit renseigne `ai.engines` avec des informations descriptives vérifiées, jamais avec des secrets. Le dashboard ne maintient plus de liste statique d’applications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, d’URL, de catégorie et de statut entre les deux interfaces. diff --git a/app.json b/app.json index cfcb864..316342b 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,6 @@ { "id": "manus-dashboard", - "name": "Dashboard", + "name": "Dashboard Production", "category": "INFRA", "urls": { "recette": "https://dashboard.recette.santinova-soft.org", diff --git a/docker-compose.yml b/docker-compose.yml index a454dd0..d58d158 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,58 +1,47 @@ services: dashboard: build: - # Les sources actives sont versionnées sous src/. Le contexte racine - # pouvait réutiliser d’anciens dossiers backend/frontend restés sur l’hôte. context: ./src dockerfile: Dockerfile container_name: manus-dashboard - # Le dashboard doit repartir seul après un arrêt anormal du processus. + # Redémarrage automatique après un arrêt anormal ; le processus reste borné. restart: always mem_limit: 512m + privileged: true env_file: - .env environment: - NODE_ENV=${NODE_ENV:-production} - - DASHBOARD_ENV=${DASHBOARD_ENV:-recette} + - DASHBOARD_ENV=prod - PORT=${PORT:-3001} - - JWT_SECRET=${JWT_SECRET:?JWT_SECRET requis dans le fichier .env serveur} - - ADMIN_USERNAME=${ADMIN_USERNAME:?ADMIN_USERNAME requis dans le fichier .env serveur} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD requis dans le fichier .env serveur} - - GITEA_URL=${GITEA_URL:?GITEA_URL requis dans le fichier .env serveur} - - GITEA_USERNAME=${GITEA_USERNAME:-manus-admin} - - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur} - - GITEA_RECETTE_URL=${GITEA_RECETTE_URL:-http://gitea:3000} - - GITEA_RECETTE_PUBLIC_URL=${GITEA_RECETTE_PUBLIC_URL:-https://git.recette.santinova-soft.org} - - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN:-} - - GITEA_PROD_URL=${GITEA_PROD_URL:-https://git.santinova-soft.org} - - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-} - - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} - - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - - DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-} - - CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox - - APPS_BASE_PATH=/opt/manus-deploy/apps - - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure + - JWT_SECRET=${JWT_SECRET} + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - GITEA_URL=${GITEA_URL} + - GITEA_USERNAME=${GITEA_USERNAME} + - GITEA_PASSWORD=${GITEA_PASSWORD} + - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis} + - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis} + - GITEA_RECETTE_URL=${GITEA_RECETTE_URL} + - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN} + - GITEA_PASSWORD_PROD=${GITEA_PASSWORD} + - APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps} + - INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure} # Contrôles bornés et dédoublonnés par healthcheck.js. - - HEALTH_CHECK_INTERVAL=60000 + - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} 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" - - "git.recette.santinova-soft.org:78.138.58.109" networks: - web labels: - "traefik.enable=true" - # Le domaine public est spécifique au serveur, mais le commit reste commun. - - "traefik.http.routers.manus-dashboard.rule=Host(`${DASHBOARD_DOMAIN:?DASHBOARD_DOMAIN requis dans le fichier .env serveur}`)" + - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc" - "traefik.http.routers.manus-dashboard.priority=100" - # Service - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.docker.network=web" diff --git a/src/backend/package.json b/src/backend/package.json index 05268d9..0e20165 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" + "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/healthcheck-category.test.js && node test/applications-ui-contract.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" }, "dependencies": { "express": "^4.21.0", diff --git a/src/backend/src/ai-catalog.js b/src/backend/src/ai-catalog.js new file mode 100644 index 0000000..0586f58 --- /dev/null +++ b/src/backend/src/ai-catalog.js @@ -0,0 +1,70 @@ +/** + * Moteurs IA vérifiés pour les applications historiques dont le manifeste ne + * contient pas encore ai.engines. Un champ ai.engines dans app.json reste + * prioritaire et permet à chaque application de remplacer cette valeur. + * + * Ce catalogue contient uniquement des informations descriptives publiques : + * aucun secret, endpoint privé ou paramètre d'exécution n'y figure. + */ +const AI_CATALOG = Object.freeze({ + 'demat-facturation-dsi': [ + { provider: 'Mistral AI', model: 'mistral-large-latest', role: 'Extraction de factures' }, + { provider: 'Google Gemini', model: 'gemini-2.0-flash', role: 'Relève de traitement' }, + ], + 'facturation-santinova': [ + { provider: 'OpenAI', model: 'gpt-4.1-mini', role: 'Traitement documentaire' }, + ], + 'falc-generator': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, + { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie' }, + ], + 'formation-manager-itinova': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'gestion-at-v2': [ + { provider: 'Manus Forge', model: 'gemini-3-flash-preview', role: 'Analyse CERFA' }, + { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images' }, + ], + 'gestion-llm-locaux': [ + { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur privé approuvé' }, + ], + 'itinova-budget-si': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'itinova-contacts': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée' }, + ], + 'itinova-podcasts': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'itinova-vehicle-exchange': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'pilotage-masse-salariale': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + portee: [ + { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'referentiel-ia': [ + { provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Moteur référencé' }, + { provider: 'OpenAI', model: 'gpt-5.4-mini', role: 'Moteur référencé' }, + { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Moteur référencé' }, + { provider: 'Mistral AI', model: 'mistral-large', role: 'Moteur référencé' }, + { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur référencé' }, + ], + sonum: [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + ], + 'veille-reglementaire': [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Classification réglementaire' }, + ], +}); + +function getAiCatalogEntry(appId) { + return AI_CATALOG[appId] || []; +} + +module.exports = { AI_CATALOG, getAiCatalogEntry }; diff --git a/src/backend/src/app-registry.js b/src/backend/src/app-registry.js index eb452be..3d74ce4 100644 --- a/src/backend/src/app-registry.js +++ b/src/backend/src/app-registry.js @@ -7,6 +7,7 @@ */ const fs = require('fs'); const path = require('path'); +const { getAiCatalogEntry } = require('./ai-catalog'); const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']); const ENVIRONMENTS = new Set(['recette', 'prod']); @@ -20,6 +21,40 @@ function isHttpsUrl(value) { } } +/** + * Normalise les moteurs IA déclarés dans app.json. Seules des métadonnées + * descriptives (fournisseur, modèle, rôle) sont acceptées : aucune clé ni URL + * technique ne doit être publiée par le dashboard. + */ +function normalizeAi(ai, appId) { + if (ai === undefined) return { engines: getAiCatalogEntry(appId) }; + if (!ai || typeof ai !== 'object' || Array.isArray(ai) || !Array.isArray(ai.engines)) { + throw new Error('ai doit être un objet avec un tableau engines'); + } + + return { + engines: ai.engines.map((engine, index) => { + if (!engine || typeof engine !== 'object' || Array.isArray(engine)) { + throw new Error(`ai.engines[${index}] doit être un objet`); + } + if (typeof engine.provider !== 'string' || engine.provider.trim().length === 0) { + throw new Error(`ai.engines[${index}].provider absent ou invalide`); + } + if (typeof engine.model !== 'string' || engine.model.trim().length === 0) { + throw new Error(`ai.engines[${index}].model absent ou invalide`); + } + if (engine.role !== undefined && (typeof engine.role !== 'string' || engine.role.trim().length === 0)) { + throw new Error(`ai.engines[${index}].role invalide`); + } + return { + provider: engine.provider.trim(), + model: engine.model.trim(), + ...(engine.role ? { role: engine.role.trim() } : {}), + }; + }), + }; +} + /** * Valide puis normalise un manifeste app.json. * Les champs dérivés (directory et healthCheckUrl) ne sont pas écrits dans le @@ -54,10 +89,12 @@ function normalizeManifest(manifest, directory, environment) { const displayName = manifest.id === 'manus-dashboard' ? `Dashboard ${environment === 'prod' ? 'Production' : 'Recette'}` : manifest.name.trim(); + const ai = normalizeAi(manifest.ai, manifest.id); return { ...manifest, name: displayName, + ai, directory, containerName: manifest.containerName || manifest.id, healthCheckUrl: manifest.urls[environment], diff --git a/src/backend/src/config.js b/src/backend/src/config.js index c3c1d2e..649f277 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -27,7 +27,7 @@ module.exports = { username: process.env.GITEA_USERNAME || 'manus-admin', token: process.env.GITEA_TOKEN || null, productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org', - productionToken: process.env.GITEA_TOKEN_PROD || null, + productionToken: process.env.GITEA_TOKEN_PROD || process.env.GITEA_TOKEN || null, }, dsiAlerts: { diff --git a/src/backend/src/healthcheck.js b/src/backend/src/healthcheck.js index cdd09bc..ac63c91 100644 --- a/src/backend/src/healthcheck.js +++ b/src/backend/src/healthcheck.js @@ -63,8 +63,9 @@ async function checkApp(appConfig) { id: appConfig.id, name: appConfig.name, description: appConfig.description || '', - // Préserve la catégorie du manifeste pour le rendu différencié des services INFRA. + // Préserve la catégorie et les métadonnées IA du manifeste pour l’interface. category: appConfig.category || 'SANTINOVA', + ai: appConfig.ai || { engines: [] }, urls: appConfig.urls, containerName: appConfig.containerName, lastCheck: new Date().toISOString(), diff --git a/src/backend/test/app-registry.test.js b/src/backend/test/app-registry.test.js index 2fb1134..bfd1692 100644 --- a/src/backend/test/app-registry.test.js +++ b/src/backend/test/app-registry.test.js @@ -24,6 +24,9 @@ try { category: 'ITINOVA', containerName: 'itinova-contacts', image: 'images/itinova-contacts.png', + ai: { + engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }], + }, urls: { recette: 'https://contacts.recette.santinova-soft.org', prod: 'https://contacts.santinova-soft.org', @@ -40,21 +43,46 @@ try { }, }); + writeManifest(sandbox, 'falc', { + id: 'falc-generator', + name: 'FALC Generator', + category: 'ITINOVA', + urls: { recette: 'https://falc.recette.santinova-soft.org' }, + }); + writeManifest(sandbox, 'invalide', { id: 'app-invalide', name: 'Application invalide', category: 'INCONNUE', urls: { recette: 'https://invalide.recette.santinova-soft.org' }, }); + writeManifest(sandbox, 'ia-invalide', { + id: 'app-ia-invalide', + name: 'Application IA invalide', + category: 'ITINOVA', + ai: { engines: [{ provider: 'Google Gemini' }] }, + urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' }, + }); const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' }); - assert.equal(discovery.apps.length, 2, 'un manifeste invalide doit être exclu'); - assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée'); + assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu'); + assert.equal(discovery.errors.length, 2, 'une erreur explicite doit être retournée pour chaque manifeste invalide'); const app = discovery.apps.find((candidate) => candidate.id === 'itinova-contacts'); assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste'); assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant'); assert.equal(app.containerName, 'itinova-contacts'); + assert.deepEqual(app.ai, { + engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }], + }, 'la métadonnée IA descriptive doit être conservée'); + + const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator'); + assert.deepEqual(falc.ai, { + engines: [ + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, + { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie' }, + ], + }, 'le catalogue IA doit compléter un manifeste historique sans moteur déclaré'); const dashboardRecette = discovery.apps.find((candidate) => candidate.id === 'manus-dashboard'); assert.equal(dashboardRecette.name, 'Dashboard Recette'); @@ -68,7 +96,7 @@ try { apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }], }; const refresh = refreshApps(config); - assert.equal(refresh.apps.length, 2, 'les applications non déployées ne doivent pas rester en cache'); + assert.equal(refresh.apps.length, 3, 'les applications non déployées ne doivent pas rester en cache'); assert.equal( config.apps.find((candidate) => candidate.id === 'itinova-contacts').name, 'Itinova Contacts', diff --git a/src/backend/test/applications-ui-contract.test.js b/src/backend/test/applications-ui-contract.test.js new file mode 100644 index 0000000..816834a --- /dev/null +++ b/src/backend/test/applications-ui-contract.test.js @@ -0,0 +1,21 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const appCard = fs.readFileSync( + path.resolve(__dirname, '../../frontend/src/components/AppCard.jsx'), + 'utf8' +); +const appSorting = fs.readFileSync( + path.resolve(__dirname, '../../frontend/src/utils/appSorting.js'), + 'utf8' +); + +assert.match(appCard, /const isInfra = app\.category === 'INFRA';/); +assert.doesNotMatch(appCard, /\['portail-santinova', 'manus-dashboard'\]\.includes\(app\.id\)/); +assert.match(appCard, /const aiEngines = Array\.isArray\(app\.ai\?\.engines\) \? app\.ai\.engines : \[\];/); +assert.match(appCard, />\s*Moteur IA\s*); +assert.match(appCard, /Non déclaré/); +assert.match(appSorting, /app\?\.category === 'INFRA' \? 0 : 1/); + +console.log('applications-ui-contract: OK'); diff --git a/src/backend/test/healthcheck-category.test.js b/src/backend/test/healthcheck-category.test.js index fe030a4..d88ac3b 100644 --- a/src/backend/test/healthcheck-category.test.js +++ b/src/backend/test/healthcheck-category.test.js @@ -26,11 +26,13 @@ const { checkApp } = require('../src/healthcheck'); id: 'referentiel-ia', name: 'Référentiel IA Santinova', category: 'INFRA', + ai: { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] }, urls: {}, containerName: 'referentiel-ia', }); assert.equal(result.category, 'INFRA'); + assert.deepEqual(result.ai, { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] }); console.log('healthcheck-category: OK'); })().catch((error) => { console.error(error); diff --git a/src/frontend/index.html b/src/frontend/index.html index 2cbebf5..a6e7555 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -4,7 +4,7 @@ -
{engine.provider}
++ {engine.model}{engine.role ? ` — ${engine.role}` : ''} +
+Non déclaré
+ )} +{ENVIRONMENT_LABEL}
+Production
Gestion des applications
- Santinova Soft — Serveur de {ENVIRONMENT_LABEL_LOWER} + Santinova Soft — Serveur de production