fix: rétablir le dashboard production et ses moteurs IA
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 1m7s

This commit is contained in:
Santinova Deploy
2026-09-05 14:21:53 +00:00
parent 1c9a9eff8f
commit 692762674b
18 changed files with 229 additions and 56 deletions

View File

@@ -19,6 +19,15 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac
"giteaRepo": "itinova-contacts", "giteaRepo": "itinova-contacts",
"giteaOwner": "manus-admin", "giteaOwner": "manus-admin",
"image": "images/itinova-contacts.png", "image": "images/itinova-contacts.png",
"ai": {
"engines": [
{
"provider": "Google Gemini",
"model": "gemini-2.5-flash",
"role": "Assistant conversationnel"
}
]
},
"urls": { "urls": {
"recette": "https://contacts.recette.santinova-soft.org", "recette": "https://contacts.recette.santinova-soft.org",
"prod": "https://contacts.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. | | `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. | | `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. |
| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. | | `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.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. |
| `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. | | `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. |
## Règles de déploiement ## 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. 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.

View File

@@ -1,6 +1,6 @@
{ {
"id": "manus-dashboard", "id": "manus-dashboard",
"name": "Dashboard", "name": "Dashboard Production",
"category": "INFRA", "category": "INFRA",
"urls": { "urls": {
"recette": "https://dashboard.recette.santinova-soft.org", "recette": "https://dashboard.recette.santinova-soft.org",

View File

@@ -1,58 +1,47 @@
services: services:
dashboard: dashboard:
build: 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 context: ./src
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: manus-dashboard 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 restart: always
mem_limit: 512m mem_limit: 512m
privileged: true
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- DASHBOARD_ENV=${DASHBOARD_ENV:-recette} - DASHBOARD_ENV=prod
- PORT=${PORT:-3001} - PORT=${PORT:-3001}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET requis dans le fichier .env serveur} - JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:?ADMIN_USERNAME requis dans le fichier .env serveur} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD requis dans le fichier .env serveur} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GITEA_URL=${GITEA_URL:?GITEA_URL requis dans le fichier .env serveur} - GITEA_URL=${GITEA_URL}
- GITEA_USERNAME=${GITEA_USERNAME:-manus-admin} - GITEA_USERNAME=${GITEA_USERNAME}
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur} - GITEA_PASSWORD=${GITEA_PASSWORD}
- GITEA_RECETTE_URL=${GITEA_RECETTE_URL:-http://gitea:3000} - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis}
- GITEA_RECETTE_PUBLIC_URL=${GITEA_RECETTE_PUBLIC_URL:-https://git.recette.santinova-soft.org} - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis}
- GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN:-} - GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
- GITEA_PROD_URL=${GITEA_PROD_URL:-https://git.santinova-soft.org} - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-} - GITEA_PASSWORD_PROD=${GITEA_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps}
- IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} - INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure}
- 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
# Contrôles bornés et dédoublonnés par healthcheck.js. # Contrôles bornés et dédoublonnés par healthcheck.js.
- HEALTH_CHECK_INTERVAL=60000 - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy - /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: networks:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# Le domaine public est spécifique au serveur, mais le commit reste commun. - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)"
- "traefik.http.routers.manus-dashboard.rule=Host(`${DASHBOARD_DOMAIN:?DASHBOARD_DOMAIN requis dans le fichier .env serveur}`)"
- "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc" - "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc"
- "traefik.http.routers.manus-dashboard.priority=100" - "traefik.http.routers.manus-dashboard.priority=100"
# Service
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=web" - "traefik.docker.network=web"

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon 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": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",

View File

@@ -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 };

View File

@@ -7,6 +7,7 @@
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { getAiCatalogEntry } = require('./ai-catalog');
const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']); const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
const ENVIRONMENTS = new Set(['recette', 'prod']); 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. * Valide puis normalise un manifeste app.json.
* Les champs dérivés (directory et healthCheckUrl) ne sont pas écrits dans le * 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' const displayName = manifest.id === 'manus-dashboard'
? `Dashboard ${environment === 'prod' ? 'Production' : 'Recette'}` ? `Dashboard ${environment === 'prod' ? 'Production' : 'Recette'}`
: manifest.name.trim(); : manifest.name.trim();
const ai = normalizeAi(manifest.ai, manifest.id);
return { return {
...manifest, ...manifest,
name: displayName, name: displayName,
ai,
directory, directory,
containerName: manifest.containerName || manifest.id, containerName: manifest.containerName || manifest.id,
healthCheckUrl: manifest.urls[environment], healthCheckUrl: manifest.urls[environment],

View File

@@ -27,7 +27,7 @@ module.exports = {
username: process.env.GITEA_USERNAME || 'manus-admin', username: process.env.GITEA_USERNAME || 'manus-admin',
token: process.env.GITEA_TOKEN || null, token: process.env.GITEA_TOKEN || null,
productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org', 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: { dsiAlerts: {

View File

@@ -63,8 +63,9 @@ async function checkApp(appConfig) {
id: appConfig.id, id: appConfig.id,
name: appConfig.name, name: appConfig.name,
description: appConfig.description || '', 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', category: appConfig.category || 'SANTINOVA',
ai: appConfig.ai || { engines: [] },
urls: appConfig.urls, urls: appConfig.urls,
containerName: appConfig.containerName, containerName: appConfig.containerName,
lastCheck: new Date().toISOString(), lastCheck: new Date().toISOString(),

View File

@@ -24,6 +24,9 @@ try {
category: 'ITINOVA', category: 'ITINOVA',
containerName: 'itinova-contacts', containerName: 'itinova-contacts',
image: 'images/itinova-contacts.png', image: 'images/itinova-contacts.png',
ai: {
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }],
},
urls: { urls: {
recette: 'https://contacts.recette.santinova-soft.org', recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.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', { writeManifest(sandbox, 'invalide', {
id: 'app-invalide', id: 'app-invalide',
name: 'Application invalide', name: 'Application invalide',
category: 'INCONNUE', category: 'INCONNUE',
urls: { recette: 'https://invalide.recette.santinova-soft.org' }, 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' }); const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' });
assert.equal(discovery.apps.length, 2, 'un manifeste invalide doit être exclu'); assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée'); 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'); 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.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.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant');
assert.equal(app.containerName, 'itinova-contacts'); 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'); const dashboardRecette = discovery.apps.find((candidate) => candidate.id === 'manus-dashboard');
assert.equal(dashboardRecette.name, 'Dashboard Recette'); assert.equal(dashboardRecette.name, 'Dashboard Recette');
@@ -68,7 +96,7 @@ try {
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }], apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
}; };
const refresh = refreshApps(config); 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( assert.equal(
config.apps.find((candidate) => candidate.id === 'itinova-contacts').name, config.apps.find((candidate) => candidate.id === 'itinova-contacts').name,
'Itinova Contacts', 'Itinova Contacts',

View File

@@ -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');

View File

@@ -26,11 +26,13 @@ const { checkApp } = require('../src/healthcheck');
id: 'referentiel-ia', id: 'referentiel-ia',
name: 'Référentiel IA Santinova', name: 'Référentiel IA Santinova',
category: 'INFRA', category: 'INFRA',
ai: { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] },
urls: {}, urls: {},
containerName: 'referentiel-ia', containerName: 'referentiel-ia',
}); });
assert.equal(result.category, 'INFRA'); assert.equal(result.category, 'INFRA');
assert.deepEqual(result.ai, { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] });
console.log('healthcheck-category: OK'); console.log('healthcheck-category: OK');
})().catch((error) => { })().catch((error) => {
console.error(error); console.error(error);

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard - Gestion des Applications</title> <title>Dashboard Production - Gestion des Applications</title>
</head> </head>
<body class="bg-dark-900 text-white"> <body class="bg-dark-900 text-white">
<div id="root"></div> <div id="root"></div>

View File

@@ -118,8 +118,9 @@ export default function AppCard({ app, commits, onRefresh }) {
const latestCommit = commits && commits.length > 0 ? commits[0] : null; const latestCommit = commits && commits.length > 0 ? commits[0] : null;
// Apps d'infrastructure : portail et dashboard // Le manifeste est la seule source de vérité pour le type et les moteurs IA.
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id); const isInfra = app.category === 'INFRA';
const aiEngines = Array.isArray(app.ai?.engines) ? app.ai.engines : [];
return ( return (
<div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}> <div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
@@ -217,7 +218,26 @@ export default function AppCard({ app, commits, onRefresh }) {
)} )}
{/* Info Grid */} {/* Info Grid */}
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="p-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{/* Moteurs IA déclarés dans app.json */}
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Moteur IA
</span>
<div className="mt-1.5 space-y-2">
{aiEngines.length > 0 ? aiEngines.map((engine, index) => (
<div key={`${engine.provider}-${engine.model}-${index}`}>
<p className="text-sm text-gray-200">{engine.provider}</p>
<p className="text-xs text-primary-400 break-words">
{engine.model}{engine.role ? ` — ${engine.role}` : ''}
</p>
</div>
)) : (
<p className="text-sm text-gray-600">Non déclaré</p>
)}
</div>
</div>
{/* URLs */} {/* URLs */}
<div> <div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider"> <span className="text-xs font-medium text-gray-500 uppercase tracking-wider">

View File

@@ -12,7 +12,6 @@ import { LayoutList, TerminalSquare, ShieldCheck,
Activity, Activity,
BookOpen, BookOpen,
} from 'lucide-react'; } from 'lucide-react';
import { ENVIRONMENT_LABEL } from '../utils/environment';
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard }, { id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'apps', label: 'Applications', icon: Box }, { id: 'apps', label: 'Applications', icon: Box },
@@ -41,7 +40,7 @@ export default function Sidebar({
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-white">Dashboard</h1> <h1 className="text-lg font-bold text-white">Dashboard</h1>
<p className="text-xs text-gray-500">{ENVIRONMENT_LABEL}</p> <p className="text-xs text-gray-500">Production</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,6 @@ import {
import { getApps, getSystemInfo } from '../utils/api'; import { getApps, getSystemInfo } from '../utils/api';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { sortAppsWithInfraFirst } from '../utils/appSorting'; import { sortAppsWithInfraFirst } from '../utils/appSorting';
import { ENVIRONMENT_LABEL, GITEA_HOST, GITEA_URL, SERVER_PUBLIC_IP } from '../utils/environment';
function StatCard({ icon: Icon, label, value, color, subtext }) { function StatCard({ icon: Icon, label, value, color, subtext }) {
return ( return (
@@ -102,7 +101,7 @@ export default function DashboardPage({ apps }) {
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">
<span className="text-sm text-gray-400">Serveur</span> <span className="text-sm text-gray-400">Serveur</span>
<span className="text-sm text-gray-200"> <span className="text-sm text-gray-200">
{SERVER_PUBLIC_IP} ({ENVIRONMENT_LABEL}) 180.149.196.138 (Production)
</span> </span>
</div> </div>
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">
@@ -115,12 +114,12 @@ export default function DashboardPage({ apps }) {
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">
<span className="text-sm text-gray-400">Gitea</span> <span className="text-sm text-gray-400">Gitea</span>
<a <a
href={GITEA_URL} href="https://git.santinova-soft.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary-400 hover:text-primary-300" className="text-sm text-primary-400 hover:text-primary-300"
> >
{GITEA_HOST} git.santinova-soft.org
</a> </a>
</div> </div>
<div className="flex items-center justify-between py-2 border-b border-dark-700"> <div className="flex items-center justify-between py-2 border-b border-dark-700">

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react'; import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import { ENVIRONMENT_LABEL } from '../utils/environment';
const APPS_CONFIG = [ const APPS_CONFIG = [
{ {
@@ -90,7 +89,7 @@ const APPS_CONFIG = [
}, },
{ {
id: 'manus-dashboard', id: 'manus-dashboard',
name: `Dashboard ${ENVIRONMENT_LABEL}`, name: 'Dashboard Production',
repoName: 'manus-dashboard', repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.santinova-soft.org', urlRecette: 'https://dashboard.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org', urlProd: 'https://dashboard.santinova-soft.org',

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Lock, User, AlertCircle, Server } from 'lucide-react'; import { Lock, User, AlertCircle, Server } from 'lucide-react';
import { login } from '../utils/api'; import { login } from '../utils/api';
import { ENVIRONMENT_LABEL, ENVIRONMENT_LABEL_LOWER } from '../utils/environment';
export default function LoginPage({ onLogin }) { export default function LoginPage({ onLogin }) {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -35,7 +34,7 @@ export default function LoginPage({ onLogin }) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
<Server className="w-8 h-8 text-white" /> <Server className="w-8 h-8 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Dashboard {ENVIRONMENT_LABEL}</h1> <h1 className="text-2xl font-bold text-white">Dashboard Production</h1>
<p className="text-gray-400 mt-2">Gestion des applications</p> <p className="text-gray-400 mt-2">Gestion des applications</p>
</div> </div>
@@ -131,7 +130,7 @@ export default function LoginPage({ onLogin }) {
</div> </div>
<p className="text-center text-gray-600 text-sm mt-6"> <p className="text-center text-gray-600 text-sm mt-6">
Santinova Soft &mdash; Serveur de {ENVIRONMENT_LABEL_LOWER} Santinova Soft &mdash; Serveur de production
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Terminal, Wifi, WifiOff, X, Plus, ChevronDown } from 'lucide-react'; import { Terminal, Wifi, WifiOff, X, Plus, ChevronDown } from 'lucide-react';
import { DEFAULT_SSH_PRESET, ENVIRONMENT_LABEL, SERVER_PUBLIC_IP } from '../utils/environment';
// Serveurs SSH prédéfinis // Serveurs SSH prédéfinis
const PRESET_SERVERS = [ const PRESET_SERVERS = [
@@ -31,8 +30,8 @@ export default function TerminalPage() {
// Formulaire de connexion // Formulaire de connexion
const [form, setForm] = useState({ const [form, setForm] = useState({
preset: DEFAULT_SSH_PRESET, preset: 'recette',
host: SERVER_PUBLIC_IP, host: '78.138.58.109',
port: 22, port: 22,
username: 'root', username: 'root',
password: '', password: '',
@@ -117,7 +116,7 @@ export default function TerminalPage() {
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m'); term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
term.writeln(`\x1b[1;34m║ Terminal SSH — Dashboard ${ENVIRONMENT_LABEL} Santinova ║\x1b[0m`); term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Production Santinova ║\x1b[0m');
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m'); term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
term.writeln(''); term.writeln('');
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m'); term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');