Compare commits

..

9 Commits

Author SHA1 Message Date
Santinova Deployment
7d1558b3b6 fix: supprimer les dernières URL historiques
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 38s
2026-10-25 02:16:22 +01:00
Santinova Deployment
575d749c52 fix: retirer les alias URL historiques
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 45s
2026-10-25 02:57:28 +02:00
Santinova Deployment
a6c1dab3d7 fix: rafraîchir les manifests du dashboard après déploiement
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 40s
2026-10-24 19:38:44 +00:00
Santinova Deployment
9fd5dbb0df feat: normaliser les URL production par entité
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 58s
2026-10-24 17:06:49 +00:00
Manus
3c000c37af fix: référencer les portails nommés par entité
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 42s
2026-10-24 13:44:44 +00:00
Manus
df1dd3d15b fix: aligner le catalogue IA FALC sur le routage réel
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 42s
2026-09-16 17:21:33 +02:00
Manus Deploy
85a1415e05 feat: synchroniser la configuration IA active du dashboard
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 43s
2026-09-10 00:28:08 +00:00
Santinova Deploy
7d7006ab29 feat: afficher coût et provenance des moteurs IA
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 50s
2026-09-05 15:06:08 +00:00
Santinova Deploy
692762674b 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
2026-09-05 14:21:53 +00:00
35 changed files with 740 additions and 149 deletions

View File

@@ -19,9 +19,21 @@ 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",
"billing": "paid",
"source": "application"
}
],
"activeConfigurationPath": "/api/ai/active-configuration"
},
"urls": { "urls": {
"recette": "https://contacts.recette.santinova-soft.org", "recette": "https://contacts-itinova.recette.santinova-soft.org",
"prod": "https://contacts.santinova-soft.org" "prod": "https://contacts-itinova.santinova-soft.org"
} }
} }
``` ```
@@ -35,11 +47,13 @@ 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`, `role` optionnel, `billing` (`free` ou `paid`) et `source` (`application` ou `referential`). Ne jamais y mettre une clé, un jeton ni un endpoint privé. |
| `ai.activeConfigurationPath` | Configuration IA active | Route locale facultative sous `/api/` qui renvoie la sélection active sous la forme `{ active, engines }`. Le dashboard la compose avec l’URL locale du manifeste ; aucune URL externe ni donnée sensible n’est acceptée. |
| `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. `billing: "free"` signifie qu’aucune facturation d’API externe n’est appliquée ; `billing: "paid"` couvre les accès facturés ou consommant un crédit. `source: "application"` affiche « Moteur IA configuré dans l’applicatif » tandis que `source: "referential"` affiche « Utilisation du référentiel IA ».
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,43 +1,34 @@
# Stage 1: Build frontend # Stage 1: Build frontend from Git-tracked sources
FROM node:20-alpine AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
# Copy frontend package files # Copy frontend package files and source from the versioned src tree.
COPY frontend/package.json frontend/pnpm-lock.yaml* ./ COPY src/frontend/package.json src/frontend/pnpm-lock.yaml* ./
RUN npm install RUN npm install
COPY src/frontend/ ./
# Copy frontend source
COPY frontend/ ./
# Build frontend
RUN npm run build RUN npm run build
# Stage 2: Production # Stage 2: Production runtime
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install docker CLI for docker compose commands # Docker controls are required by the dashboard runtime.
RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh-client 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" && \ RUN git config --global user.email "admin@santinova-soft.org" && \
git config --global user.name "Manus Dashboard" git config --global user.name "Manus Dashboard"
# Copy backend # Copy backend dependencies and source from the versioned src tree.
COPY backend/package.json ./backend/ COPY src/backend/package.json src/backend/package-lock.json ./backend/
WORKDIR /app/backend WORKDIR /app/backend
RUN npm install --production RUN npm ci --omit=dev
COPY src/backend/ ./
COPY backend/ ./ # Copy the frontend built from the same Git revision.
# Copy built frontend
WORKDIR /app WORKDIR /app
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
WORKDIR /app/backend WORKDIR /app/backend
EXPOSE 3001 EXPOSE 3001
CMD ["node", "src/index.js"] CMD ["node", "src/index.js"]

View File

@@ -28,5 +28,5 @@ docker compose build --no-cache && docker compose up -d
## Accès ## Accès
- URL : https://dashboard.santinova-soft.org - URL : https://dashboard-santinova.santinova-soft.org
- Login : adminItinova / Itinova69! - Login : adminItinova / Itinova69!

View File

@@ -1,10 +1,10 @@
{ {
"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-santinova.recette.santinova-soft.org",
"prod": "https://dashboard.santinova-soft.org" "prod": "https://dashboard-santinova.santinova-soft.org"
}, },
"containerName": "manus-dashboard", "containerName": "manus-dashboard",
"image": "images/dashboard.png", "image": "images/dashboard.png",

View File

@@ -201,11 +201,11 @@ module.exports = {
giteaRepo: 'portail-santinova', giteaRepo: 'portail-santinova',
giteaOwner: 'manus-admin', giteaOwner: 'manus-admin',
urls: { urls: {
recette: 'https://portail.recette.santinova-soft.org', recette: 'https://portail-santinova.recette.santinova-soft.org',
prod: 'https://portail.santinova-soft.org', prod: 'https://portail-santinova.santinova-soft.org',
}, },
containerName: 'portail-santinova', containerName: 'portail-santinova',
healthCheckUrl: 'https://portail.recette.santinova-soft.org', healthCheckUrl: 'https://portail-santinova.recette.santinova-soft.org',
port: 3000, port: 3000,
category: 'INFRA', category: 'INFRA',
status: 'production', status: 'production',

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.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

@@ -0,0 +1,16 @@
flowchart TD
APP[Application cliente] --> REF[Référentiel IA Santinova]
REF --> DEC{Mode de décision}
DEC -->|Recommandation| REC[Meilleur moteur autorisé]
DEC -->|Choix manuel| MAN[Moteur externe autorisé]
DEC -->|Moteur IA local| LOCAL[Moteur IA local]
LOCAL --> OLLAMA[Ollama : modèle disponible]
OLLAMA -->|Disponible| LOCAL_RESPONSE[Réponse locale]
OLLAMA -->|Indisponible + repli activé| FALLBACK[Moteur externe de repli autorisé]
REC --> EXTERNAL[Passerelle fournisseur]
MAN --> EXTERNAL
FALLBACK --> EXTERNAL
EXTERNAL --> EXTERNAL_RESPONSE[Réponse externe]
REF --> BUDGETS[Budgets, plafonds et traçabilité]
LOCAL_RESPONSE --> BUDGETS
EXTERNAL_RESPONSE --> BUDGETS

25
docs/ai-infrastructure.md Normal file
View File

@@ -0,0 +1,25 @@
# Architecture IA — Référentiel IA et Moteur IA local
## Finalité
Le dispositif IA de Santinova sépare la **décision de routage**, assurée par le Référentiel IA, de l’**exécution locale**, assurée par le projet Moteur IA local. Chaque application cliente utilise uniquement le contrat sécurisé du Référentiel IA : aucune clé fournisseur, URL Ollama privée ou information d’accès interne n’est exposée aux applications.
| Composant | Rôle | Informations exposées aux applications |
|---|---|---|
| Référentiel IA Santinova | Catalogue, recommandation, décision effective, budgets, limites et passerelle d’invocation. | Moteur retenu, modèle, règles d’usage et contrat d’appel sécurisé. |
| Moteur IA local | Disponibilité du service local, modèle Ollama prêt et exécution privée des conversations. | Modèle et statut public uniquement. |
| Fournisseurs externes | Exécution des appels explicitement autorisés par application. | Aucun secret n’est transmis par le Référentiel. |
## Décision appliquée par application
Le Référentiel IA applique l’un des trois modes définis par l’administrateur. En mode **Recommandation**, il retient le moteur le mieux adapté au cas d’usage, aux capacités demandées et aux règles de coût. En mode **Choix manuel**, il utilise exclusivement le moteur externe choisi parmi ceux autorisés pour l’application. En mode **Moteur IA local**, il utilise le modèle local disponible, actuellement `llama3.2:1b` en recette.
Le mode local peut comporter un repli externe. Ce repli est **désactivé par défaut**, doit être activé application par application et ne peut désigner qu’un moteur expressément autorisé. Lorsqu’il est déclenché, l’événement est journalisé comme un repli : l’administrateur peut distinguer les appels locaux des appels externes dans les compteurs et les plafonds.
## Contrôles de sécurité et de budget
Les budgets et limites externes restent indépendants des plafonds propres au moteur local. Une limite locale atteinte bloque l’appel local avec un retour contrôlé ; elle ne déclenche pas de repli automatique. Le repli est réservé à l’indisponibilité du service local. Les clés API externes restent chiffrées côté serveur et les communications entre le Référentiel IA et le Moteur IA local utilisent un jeton de service conservé uniquement dans les environnements serveur.
## Exploitation en recette
Les deux applications sont accessibles en environnement de recette : le **Référentiel IA Santinova** pilote la décision applicative et le **Moteur IA local** supervise Ollama ainsi que les modèles disponibles. Le dashboard d’infrastructure fournit la vue graphique de ce workflow, tandis que la page Applications du Référentiel IA permet d’ajuster le mode de décision, les autorisations, les plafonds et le moteur de repli de chaque application.

View File

@@ -53,9 +53,9 @@ const RECETTE = {
icon: Globe, icon: Globe,
color: 'teal', color: 'teal',
name: 'Portail Applicatif', name: 'Portail Applicatif',
url: 'portail.recette.santinova-soft.org', url: 'portail-santinova.recette.santinova-soft.org',
desc: 'Vitrine centralisée des applications', desc: 'Vitrine centralisée des applications',
href: 'https://portail.recette.santinova-soft.org', href: 'https://portail-santinova.recette.santinova-soft.org',
}, },
{ {
icon: Shield, icon: Shield,
@@ -141,9 +141,9 @@ const PRODUCTION = {
icon: Globe, icon: Globe,
color: 'teal', color: 'teal',
name: 'Portail Applicatif', name: 'Portail Applicatif',
url: 'portail.santinova-soft.org', url: 'portail-santinova.santinova-soft.org',
desc: 'Vitrine applicative production', desc: 'Vitrine applicative production',
href: 'https://portail.santinova-soft.org', href: 'https://portail-santinova.santinova-soft.org',
}, },
{ {
icon: Shield, icon: Shield,

View File

@@ -84,8 +84,8 @@ const APPS_CONFIG = [
id: 'portail-santinova', id: 'portail-santinova',
name: 'Portail Applicatif', name: 'Portail Applicatif',
repoName: 'portail-santinova', repoName: 'portail-santinova',
urlRecette: 'https://portail.recette.santinova-soft.org', urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
urlProd: 'https://portail.santinova-soft.org', urlProd: 'https://portail-santinova.santinova-soft.org',
}, },
{ {
id: 'manus-dashboard', id: 'manus-dashboard',

View File

@@ -4,7 +4,7 @@ set -Eeuo pipefail
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}" readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}" readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}" readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}" readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}"
backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)" backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)" temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"

View File

@@ -12,7 +12,7 @@ from pathlib import Path
GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/") GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/")
GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin") GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin")
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/") DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard-santinova.recette.santinova-soft.org").rstrip("/")
DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard")) DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard"))
ENV_FILE = DASHBOARD_APP_DIR / ".env" ENV_FILE = DASHBOARD_APP_DIR / ".env"
WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea" WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea"

View File

@@ -28,5 +28,5 @@ docker compose build --no-cache && docker compose up -d
## Accès ## Accès
- URL : https://dashboard.santinova-soft.org - URL : https://dashboard-santinova.santinova-soft.org
- Login : adminItinova / Itinova69! - Login : adminItinova / Itinova69!

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/active-ai-configuration.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,74 @@
/**
* 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.
*
* billing : coût d'accès au moteur (« free » = aucune facturation d'API externe,
* « paid » = accès fournisseur ou service hébergé payant). Les ressources de la
* VM restent hors de ce statut.
* source : « application » indique un moteur configuré par l'applicatif ;
* « referential » est réservé aux moteurs consommés via le Référentiel IA.
* Aucune clé, aucun jeton ni endpoint privé n'est publié ici.
*/
const AI_CATALOG = Object.freeze({
'demat-facturation-dsi': [
{ provider: 'Mistral AI', model: 'mistral-large-latest', role: 'Extraction de factures', billing: 'paid', source: 'application' },
{ provider: 'Google Gemini', model: 'gemini-2.0-flash', role: 'Relève de traitement', billing: 'paid', source: 'application' },
],
'facturation-santinova': [
{ provider: 'OpenAI', model: 'gpt-4.1-mini', role: 'Traitement documentaire', billing: 'paid', source: 'application' },
],
'falc-generator': [
{ provider: 'Manus Forge', model: 'modèle par défaut non explicité', role: 'Transformation FALC', billing: 'paid', source: 'application' },
{ provider: 'Manus Forge', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' },
],
'formation-manager-itinova': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'gestion-at-v2': [
{ provider: 'Manus Forge', model: 'gemini-3-flash-preview', role: 'Analyse CERFA', billing: 'paid', source: 'application' },
{ provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images', billing: 'paid', source: 'application' },
],
'gestion-llm-locaux': [
{ provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur privé approuvé', billing: 'free', source: 'application' },
],
'itinova-budget-si': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'itinova-contacts': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
{ provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée', billing: 'paid', source: 'application' },
],
'itinova-podcasts': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'itinova-vehicle-exchange': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'pilotage-masse-salariale': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
portee: [
{ provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images', billing: 'paid', source: 'application' },
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'referentiel-ia': [
{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Moteur référencé', billing: 'paid', source: 'application' },
{ provider: 'OpenAI', model: 'gpt-5.4-mini', role: 'Moteur référencé', billing: 'paid', source: 'application' },
{ provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Moteur référencé', billing: 'paid', source: 'application' },
{ provider: 'Mistral AI', model: 'mistral-large', role: 'Moteur référencé', billing: 'paid', source: 'application' },
{ provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur référencé', billing: 'free', source: 'application' },
],
sonum: [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
],
'veille-reglementaire': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Classification réglementaire', billing: 'paid', source: 'application' },
],
});
function getAiCatalogEntry(appId) {
return AI_CATALOG[appId] || [];
}
module.exports = { AI_CATALOG, getAiCatalogEntry };

View File

@@ -7,9 +7,13 @@
*/ */
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']);
const AI_BILLING = new Set(['free', 'paid']);
const AI_SOURCES = new Set(['application', 'referential']);
const DEFAULT_MANIFEST_REFRESH_INTERVAL_MS = 15_000;
function isHttpsUrl(value) { function isHttpsUrl(value) {
if (typeof value !== 'string' || value.length === 0) return false; if (typeof value !== 'string' || value.length === 0) return false;
@@ -20,6 +24,63 @@ function isHttpsUrl(value) {
} }
} }
/**
* Normalise les moteurs IA déclarés dans app.json. Seules des métadonnées
* descriptives (fournisseur, modèle, rôle, coût et provenance) 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)) {
throw new Error('ai doit être un objet');
}
if (ai.activeConfigurationPath !== undefined && (
typeof ai.activeConfigurationPath !== 'string' ||
!/^\/api\/[a-z0-9/_-]+$/i.test(ai.activeConfigurationPath)
)) {
throw new Error('ai.activeConfigurationPath doit viser une route /api locale');
}
const declaredEngines = ai.engines === undefined ? getAiCatalogEntry(appId) : ai.engines;
if (!Array.isArray(declaredEngines)) {
throw new Error('ai.engines doit être un tableau');
}
const engines = declaredEngines.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`);
}
if (engine.billing !== undefined && !AI_BILLING.has(engine.billing)) {
throw new Error(`ai.engines[${index}].billing doit être free ou paid`);
}
if (engine.source !== undefined && !AI_SOURCES.has(engine.source)) {
throw new Error(`ai.engines[${index}].source doit être application ou referential`);
}
return {
provider: engine.provider.trim(),
model: engine.model.trim(),
...(engine.role ? { role: engine.role.trim() } : {}),
// Un moteur non qualifié est traité comme payant et configuré dans
// l'applicatif pour ne pas afficher une gratuité non vérifiée.
billing: engine.billing || 'paid',
source: engine.source || 'application',
};
});
return {
engines,
...(ai.activeConfigurationPath ? { activeConfigurationPath: ai.activeConfigurationPath } : {}),
};
}
/** /**
* 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 +115,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],
@@ -109,13 +172,14 @@ function discoverApps({ appsBasePath, environment }) {
* Toute valeur obsolète est remplacée : seules les applications réellement * Toute valeur obsolète est remplacée : seules les applications réellement
* déployées et correctement déclarées restent disponibles. * déployées et correctement déclarées restent disponibles.
*/ */
function refreshApps(config, logger = console) { function refreshApps(config, logger = console, now = Date.now()) {
const result = discoverApps({ const result = discoverApps({
appsBasePath: config.appsBasePath, appsBasePath: config.appsBasePath,
environment: config.environment, environment: config.environment,
}); });
config.apps.splice(0, config.apps.length, ...result.apps); config.apps.splice(0, config.apps.length, ...result.apps);
config.appsRefreshedAt = now;
for (const issue of result.errors) { for (const issue of result.errors) {
logger.warn(`[app-registry] Manifeste ignoré (${issue.directory}) : ${issue.error}`); logger.warn(`[app-registry] Manifeste ignoré (${issue.directory}) : ${issue.error}`);
} }
@@ -124,6 +188,24 @@ function refreshApps(config, logger = console) {
return result; return result;
} }
/**
* Rafraîchit le registre après expiration, sans parcourir les applications à
* chaque requête. Les déploiements deviennent ainsi visibles sans redémarrage.
*/
function refreshAppsIfStale(config, {
logger = console,
now = Date.now(),
maxAgeMs = config.appManifestRefreshIntervalMs || DEFAULT_MANIFEST_REFRESH_INTERVAL_MS,
} = {}) {
const hasFreshCache = Array.isArray(config.apps) && config.apps.length > 0 &&
Number.isFinite(config.appsRefreshedAt) &&
now - config.appsRefreshedAt < maxAgeMs;
if (hasFreshCache) {
return { apps: config.apps, errors: [], refreshed: false };
}
return { ...refreshApps(config, logger, now), refreshed: true };
}
/** /**
* Écrit un manifeste de manière atomique puis le valide avant publication. * Écrit un manifeste de manière atomique puis le valide avant publication.
*/ */
@@ -154,6 +236,7 @@ function findAppByRepo(config, repositoryName) {
module.exports = { module.exports = {
discoverApps, discoverApps,
refreshApps, refreshApps,
refreshAppsIfStale,
writeAppManifest, writeAppManifest,
findAppDirectoryByRepo, findAppDirectoryByRepo,
findAppByRepo, findAppByRepo,

View File

@@ -27,11 +27,11 @@ 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: {
url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org', url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia-santinova.recette.santinova-soft.org',
token: process.env.IA_REFERENTIEL_TOKEN || null, token: process.env.IA_REFERENTIEL_TOKEN || null,
email: process.env.DSI_ALERT_EMAIL || null, email: process.env.DSI_ALERT_EMAIL || null,
emailQueue: process.env.CI_ALERT_EMAIL_QUEUE || '/opt/manus-deploy/secrets/ci-alerts/outbox', emailQueue: process.env.CI_ALERT_EMAIL_QUEUE || '/opt/manus-deploy/secrets/ci-alerts/outbox',

View File

@@ -51,6 +51,41 @@ async function checkUrl(url) {
} }
} }
/**
* Charge une configuration IA active exclusivement depuis une route /api locale
* validée par le registre. Le dashboard n’envoie aucun secret applicatif et ne
* propage que les métadonnées descriptives explicitement renvoyées.
*/
async function getActiveAiConfiguration(appConfig, localUrl) {
const activePath = appConfig.ai?.activeConfigurationPath;
if (!activePath || !localUrl) return null;
try {
const response = await axios.get(new URL(activePath, localUrl).toString(), {
timeout: 3500,
httpsAgent,
validateStatus: (status) => status === 200,
});
const engines = Array.isArray(response.data?.engines) ? response.data.engines : [];
const safeEngines = engines
.filter((engine) => engine && typeof engine.provider === 'string' && typeof engine.model === 'string')
.map((engine) => ({
provider: engine.provider,
model: engine.model,
...(typeof engine.role === 'string' ? { role: engine.role } : {}),
billing: engine.billing === 'free' ? 'free' : 'paid',
source: engine.source === 'referential' ? 'referential' : 'application',
}));
return {
active: response.data?.active === true && safeEngines.length > 0,
engines: safeEngines,
runtimeStatus: response.data?.active === true && safeEngines.length > 0 ? 'active' : 'unavailable',
};
} catch {
return { active: false, engines: [], runtimeStatus: 'unavailable' };
}
}
/** /**
* Vérifie une application déployée sur l'environnement du dashboard courant. * Vérifie une application déployée sur l'environnement du dashboard courant.
* Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite * Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite
@@ -63,8 +98,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(),
@@ -73,11 +109,18 @@ async function checkApp(appConfig) {
health: { recette: null, prod: null }, health: { recette: null, prod: null },
}; };
const [containerInfo, health] = await Promise.all([ const [containerInfo, health, activeAiConfiguration] = await Promise.all([
getContainerInfo(appConfig.containerName), getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null), localUrl ? checkUrl(localUrl) : Promise.resolve(null),
getActiveAiConfiguration(appConfig, localUrl),
]); ]);
if (activeAiConfiguration) {
result.ai = {
...appConfig.ai,
...activeAiConfiguration,
};
}
result.container = containerInfo; result.container = containerInfo;
result.health[environment] = health; result.health[environment] = health;
@@ -162,6 +205,7 @@ function getAllStatuses() {
module.exports = { module.exports = {
checkUrl, checkUrl,
getActiveAiConfiguration,
checkApp, checkApp,
checkAllApps, checkAllApps,
mapWithConcurrency, mapWithConcurrency,

View File

@@ -10,12 +10,24 @@ const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs,
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const { readCiAlerts } = require('./ci-alerts'); const { readCiAlerts } = require('./ci-alerts');
const config = require('./config'); const config = require('./config');
const { refreshAppsIfStale } = require('./app-registry');
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent) // Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
cleanupOrphanedDeployments(); cleanupOrphanedDeployments();
const router = express.Router(); const router = express.Router();
// Les manifests sont la source de vérité ; leur cache est rafraîchi après un
// court délai pour refléter les déploiements sans alourdir chaque requête API.
router.use((req, res, next) => {
try {
refreshAppsIfStale(config);
next();
} catch (error) {
next(error);
}
});
// Configuration multer pour l'upload de fichiers ZIP // Configuration multer pour l'upload de fichiers ZIP
const uploadDir = '/tmp/dashboard-uploads'; const uploadDir = '/tmp/dashboard-uploads';
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {

View File

@@ -0,0 +1,50 @@
const assert = require('node:assert/strict');
const axios = require('axios');
const { getActiveAiConfiguration } = require('../src/healthcheck');
(async () => {
const originalGet = axios.get;
try {
let requestedUrl = null;
axios.get = async (url) => {
requestedUrl = url;
return {
data: {
active: true,
engines: [{
provider: 'Ollama local',
model: 'llama3.2:1b',
role: 'Moteur sélectionné par le Référentiel IA',
billing: 'free',
source: 'referential',
token: 'ne-doit-jamais-etre-publie',
}],
},
};
};
const result = await getActiveAiConfiguration({
ai: { activeConfigurationPath: '/api/ai/active-configuration' },
}, 'https://contacts-itinova.recette.santinova-soft.org');
assert.equal(requestedUrl, 'https://contacts-itinova.recette.santinova-soft.org/api/ai/active-configuration');
assert.deepEqual(result, {
active: true,
runtimeStatus: 'active',
engines: [{
provider: 'Ollama local',
model: 'llama3.2:1b',
role: 'Moteur sélectionné par le Référentiel IA',
billing: 'free',
source: 'referential',
}],
});
assert.equal(JSON.stringify(result).includes('ne-doit-jamais-etre-publie'), false);
console.log('active-ai-configuration: OK');
} finally {
axios.get = originalGet;
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -7,7 +7,7 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { discoverApps, refreshApps } = require('../src/app-registry'); const { discoverApps, refreshApps, refreshAppsIfStale } = require('../src/app-registry');
function writeManifest(basePath, directory, manifest) { function writeManifest(basePath, directory, manifest) {
const appPath = path.join(basePath, directory); const appPath = path.join(basePath, directory);
@@ -24,9 +24,13 @@ 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', billing: 'free', source: 'referential' }],
activeConfigurationPath: '/api/ai/active-configuration',
},
urls: { urls: {
recette: 'https://contacts.recette.santinova-soft.org', recette: 'https://contacts-itinova.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org', prod: 'https://contacts-itinova.santinova-soft.org',
}, },
}); });
@@ -35,26 +39,60 @@ try {
name: 'Dashboard', name: 'Dashboard',
category: 'INFRA', category: 'INFRA',
urls: { urls: {
recette: 'https://dashboard.recette.santinova-soft.org', recette: 'https://dashboard-santinova.recette.santinova-soft.org',
prod: 'https://dashboard.santinova-soft.org', prod: 'https://dashboard-santinova.santinova-soft.org',
}, },
}); });
writeManifest(sandbox, 'falc', {
id: 'falc-generator',
name: 'FALC Generator',
category: 'ITINOVA',
urls: { recette: 'https://falc-itinova.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' },
});
writeManifest(sandbox, 'tarif-ia-invalide', {
id: 'app-tarif-ia-invalide',
name: 'Tarif IA invalide',
category: 'ITINOVA',
ai: { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', billing: 'inconnu' }] },
urls: { recette: 'https://tarif-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, 3, 'une erreur explicite doit être retournée pour chaque manifeste invalide');
assert.ok(discovery.errors.some((error) => error.error.includes('billing doit être free ou paid')));
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', billing: 'free', source: 'referential' }],
activeConfigurationPath: '/api/ai/active-configuration',
}, 'la métadonnée IA descriptive, tarifaire, de provenance et de configuration active doit être conservée');
const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator');
assert.deepEqual(falc.ai, {
engines: [
{ provider: 'Manus Forge', model: 'modèle par défaut non explicité', role: 'Transformation FALC', billing: 'paid', source: 'application' },
{ provider: 'Manus Forge', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' },
],
}, '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,13 +106,40 @@ 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',
'app.json doit prévaloir sur toute définition statique' 'app.json doit prévaloir sur toute définition statique'
); );
// Un déploiement met à jour app.json : le dashboard doit le détecter sans
// redémarrage, mais sans relire les manifests à chaque requête.
refreshApps(config, console, 1_000);
writeManifest(sandbox, 'contacts', {
id: 'itinova-contacts',
name: 'Itinova Contacts mis à jour',
category: 'ITINOVA',
urls: {
recette: 'https://contacts-itinova.recette.santinova-soft.org',
prod: 'https://contacts-itinova.santinova-soft.org',
},
});
const cachedRefresh = refreshAppsIfStale(config, { now: 1_999, maxAgeMs: 1_000 });
assert.equal(cachedRefresh.refreshed, false, 'le cache reste utilisable avant son expiration');
assert.equal(
config.apps.find((candidate) => candidate.id === 'itinova-contacts').name,
'Itinova Contacts',
'le cache ne doit pas relire les manifests avant son expiration'
);
const expiredRefresh = refreshAppsIfStale(config, { now: 2_000, maxAgeMs: 1_000 });
assert.equal(expiredRefresh.refreshed, true, 'le manifeste est relu lorsque le cache expire');
assert.equal(
config.apps.find((candidate) => candidate.id === 'itinova-contacts').name,
'Itinova Contacts mis à jour',
'la modification de manifeste doit apparaître sans redémarrage du dashboard'
);
console.log('OK app-registry'); console.log('OK app-registry');
} finally { } finally {
fs.rmSync(sandbox, { recursive: true, force: true }); fs.rmSync(sandbox, { recursive: true, force: true });

View File

@@ -0,0 +1,50 @@
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'
);
const historicalRecetteHost = ['portail', 'recette', 'santinova-soft', 'org'].join('.');
const historicalProductionHost = ['portail', 'santinova-soft', 'org'].join('.');
const portalMetadataSources = [
'../../frontend/src/pages/DocumentationPage.jsx',
'../../frontend/src/pages/InventairePage.jsx',
'../../../frontend/src/pages/DocumentationPage.jsx',
'../../../frontend/src/pages/InventairePage.jsx',
'../../../backend/src/config.js',
'../../frontend/public/infra_schema_v3.svg',
'../../frontend/public/infra_schema_v4.svg',
].map((relativePath) => path.resolve(__dirname, relativePath))
.filter((filePath) => fs.existsSync(filePath))
.map((filePath) => fs.readFileSync(filePath, '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, /Moteur IA actif/);
assert.match(appCard, /Non déclaré/);
assert.match(appCard, /engine\.billing === 'free'/);
assert.match(appCard, /Gratuit/);
assert.match(appCard, /Payant/);
assert.match(appCard, /bg-emerald-900\/40 text-emerald-300/);
assert.match(appCard, /bg-orange-900\/40 text-orange-300/);
assert.match(appCard, /Utilisation du référentiel IA/);
assert.match(appCard, /Moteur IA configuré dans l'applicatif/);
assert.match(appCard, /Moteur IA actif/);
assert.match(appCard, /text-emerald-300/);
assert.match(appCard, /text-orange-300/);
assert.match(appCard, /Configuration active indisponible/);
assert.match(appSorting, /app\?\.category === 'INFRA' \? 0 : 1/);
for (const source of portalMetadataSources) {
assert.equal(source.includes(historicalRecetteHost), false, 'le dashboard ne doit plus référencer le portail historique recette');
assert.equal(source.includes(historicalProductionHost), false, 'le dashboard ne doit plus référencer le portail historique production');
}
console.log('applications-ui-contract: OK');

View File

@@ -0,0 +1,14 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const dockerfile = fs.readFileSync(path.resolve(__dirname, '../../../Dockerfile'), 'utf8');
assert.match(dockerfile, /COPY src\/frontend\/package\.json src\/frontend\/pnpm-lock\.yaml\* \.\//);
assert.match(dockerfile, /COPY src\/frontend\/ \.\//);
assert.match(dockerfile, /COPY src\/backend\/package\.json src\/backend\/package-lock\.json \.\/backend\//);
assert.match(dockerfile, /COPY src\/backend\/ \.\//);
assert.doesNotMatch(dockerfile, /COPY frontend\//);
assert.doesNotMatch(dockerfile, /COPY backend\//);
console.log('OK dockerfile source paths');

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

@@ -17,7 +17,7 @@ services:
- GITEA_RECETTE_URL=http://gitea:3000 - GITEA_RECETTE_URL=http://gitea:3000
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-} - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - 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_URL=https://referentiel-ia-santinova.santinova-soft.org
- IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-}
- DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-} - DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-}
- CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox - CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox
@@ -33,8 +33,8 @@ services:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# Route HTTPS via dashboard.recette.santinova-soft.org # Route HTTPS via dashboard-santinova.santinova-soft.org
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard-santinova.santinova-soft.org`)"
- "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"

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,49 @@ 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">
{app.ai?.runtimeStatus === 'active' ? 'Moteur IA actif' : '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}`}>
<div className="flex items-center gap-2">
<p className="text-sm text-gray-200">{engine.provider}</p>
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-semibold ${
engine.billing === 'free'
? 'border-emerald-700/40 bg-emerald-900/40 text-emerald-300'
: 'border-orange-700/40 bg-orange-900/40 text-orange-300'
}`}
>
{engine.billing === 'free' ? 'Gratuit' : 'Payant'}
</span>
</div>
<p className="text-xs text-primary-400 break-words">
{engine.model}{engine.role ? ` — ${engine.role}` : ''}
</p>
<p
className={`mt-0.5 text-[11px] leading-snug ${
engine.source === 'referential' ? 'text-emerald-300' : 'text-orange-300'
}`}
>
{engine.source === 'referential'
? 'Utilisation du référentiel IA'
: "Moteur IA configuré dans l'applicatif"}
</p>
</div>
)) : (
<p className="text-sm text-gray-600">
{app.ai?.activeConfigurationPath ? 'Configuration active indisponible' : '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

@@ -19,6 +19,16 @@ import {
Clock, Clock,
Zap, Zap,
Package, Package,
Bot,
BrainCircuit,
Workflow,
ShieldCheck,
Route,
ArrowDown,
CircleAlert,
LockKeyhole,
ServerCog,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
@@ -45,17 +55,17 @@ const RECETTE = {
icon: LayoutDashboard, icon: LayoutDashboard,
color: 'blue', color: 'blue',
name: 'Dashboard', name: 'Dashboard',
url: 'dashboard.recette.santinova-soft.org', url: 'dashboard-santinova.recette.santinova-soft.org',
desc: 'Monitoring CPU / RAM / Disque', desc: 'Monitoring CPU / RAM / Disque',
href: 'https://dashboard.recette.santinova-soft.org', href: 'https://dashboard-santinova.recette.santinova-soft.org',
}, },
{ {
icon: Globe, icon: Globe,
color: 'teal', color: 'teal',
name: 'Portail Applicatif', name: 'Portail Applicatif',
url: 'portail.recette.santinova-soft.org', url: 'portail-santinova.recette.santinova-soft.org',
desc: 'Vitrine centralisée des applications', desc: 'Vitrine centralisée des applications',
href: 'https://portail.recette.santinova-soft.org', href: 'https://portail-santinova.recette.santinova-soft.org',
}, },
{ {
icon: Shield, icon: Shield,
@@ -70,44 +80,60 @@ const RECETTE = {
icon: '🧾', icon: '🧾',
color: 'green', color: 'green',
name: 'Démat. Facturation DSI', name: 'Démat. Facturation DSI',
url: 'demat-facturation.recette.santinova-soft.org', url: 'demat-facturation-santinova.recette.santinova-soft.org',
href: 'https://demat-facturation.recette.santinova-soft.org', href: 'https://demat-facturation-santinova.recette.santinova-soft.org',
}, },
{ {
icon: '👁️', icon: '👁️',
color: 'indigo', color: 'indigo',
name: 'Veille Réglementaire', name: 'Veille Réglementaire',
url: 'veille.recette.santinova-soft.org', url: 'veille-itinova.recette.santinova-soft.org',
href: 'https://veille.recette.santinova-soft.org', href: 'https://veille-itinova.recette.santinova-soft.org',
}, },
{ {
icon: '🗺️', icon: '🗺️',
color: 'cyan', color: 'cyan',
name: 'SONUM', name: 'SONUM',
url: 'sonum.recette.santinova-soft.org', url: 'sonum-santinova.recette.santinova-soft.org',
desc: 'Cartographie solutions numériques FEHAP', desc: 'Cartographie solutions numériques FEHAP',
href: 'https://sonum.recette.santinova-soft.org', href: 'https://sonum-santinova.recette.santinova-soft.org',
}, },
{ {
icon: '👤', icon: '👤',
color: 'pink', color: 'pink',
name: 'Itinova Contacts', name: 'Itinova Contacts',
url: 'contacts.recette.santinova-soft.org', url: 'contacts-itinova.recette.santinova-soft.org',
href: 'https://contacts.recette.santinova-soft.org', href: 'https://contacts-itinova.recette.santinova-soft.org',
},
{
icon: BrainCircuit,
color: 'purple',
name: 'Référentiel IA Santinova',
url: 'referentiel-ia-santinova.recette.santinova-soft.org',
desc: 'Décisions, contrats sécurisés et budgets IA',
href: 'https://referentiel-ia-santinova.recette.santinova-soft.org',
},
{
icon: ServerCog,
color: 'cyan',
name: 'Moteur IA local',
url: 'llm-local.recette.santinova-soft.org',
desc: 'Ollama privé · modèle local disponible',
href: 'https://llm-local.recette.santinova-soft.org',
}, },
{ {
icon: '🚗', icon: '🚗',
color: 'yellow', color: 'yellow',
name: 'Flotte Véhicules', name: 'Flotte Véhicules',
url: 'flotte.recette.santinova-soft.org', url: 'flotte-itinova.recette.santinova-soft.org',
href: 'https://flotte.recette.santinova-soft.org', href: 'https://flotte-itinova.recette.santinova-soft.org',
}, },
{ {
icon: '🎧', icon: '🎧',
color: 'orange', color: 'orange',
name: 'Podcasts Itinova', name: 'Podcasts Itinova',
url: 'podcasts.recette.santinova-soft.org', url: 'podcasts-itinova.recette.santinova-soft.org',
href: 'https://podcasts.recette.santinova-soft.org', href: 'https://podcasts-itinova.recette.santinova-soft.org',
}, },
], ],
}; };
@@ -133,17 +159,17 @@ const PRODUCTION = {
icon: LayoutDashboard, icon: LayoutDashboard,
color: 'blue', color: 'blue',
name: 'Dashboard', name: 'Dashboard',
url: 'dashboard.santinova-soft.org', url: 'dashboard-santinova.santinova-soft.org',
desc: 'Monitoring temps réel', desc: 'Monitoring temps réel',
href: 'https://dashboard.santinova-soft.org', href: 'https://dashboard-santinova.santinova-soft.org',
}, },
{ {
icon: Globe, icon: Globe,
color: 'teal', color: 'teal',
name: 'Portail Applicatif', name: 'Portail Applicatif',
url: 'portail.santinova-soft.org', url: 'portail-santinova.santinova-soft.org',
desc: 'Vitrine applicative production', desc: 'Vitrine applicative production',
href: 'https://portail.santinova-soft.org', href: 'https://portail-santinova.santinova-soft.org',
}, },
{ {
icon: Shield, icon: Shield,
@@ -248,6 +274,74 @@ function SectionTitle({ children, color = 'amber' }) {
return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>; return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>;
} }
function WorkflowNode({ icon: Icon, eyebrow, title, description, tone = 'blue' }) {
const tones = {
blue: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
purple: 'border-purple-500/30 bg-purple-500/10 text-purple-300',
teal: 'border-teal-500/30 bg-teal-500/10 text-teal-300',
emerald: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
};
return (
<div className={`rounded-2xl border p-4 ${tones[tone]}`}>
<div className="flex items-start gap-3">
<div className="rounded-xl bg-dark-950/35 p-2.5"><Icon className="h-5 w-5" aria-hidden="true" /></div>
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.16em] opacity-70">{eyebrow}</p>
<h4 className="mt-1 text-sm font-bold text-white">{title}</h4>
<p className="mt-1 text-xs leading-5 text-gray-300">{description}</p>
</div>
</div>
</div>
);
}
function AiDecisionWorkflow() {
const modes = [
{ icon: Sparkles, title: 'Recommandation', description: 'Le Référentiel retient le moteur adapté au cas d’usage et aux règles de coût.', tone: 'blue' },
{ icon: Route, title: 'Choix manuel', description: 'L’administrateur sélectionne un moteur externe parmi ceux autorisés.', tone: 'purple' },
{ icon: ServerCog, title: 'Moteur IA local', description: 'L’application utilise le modèle Ollama privé disponible.', tone: 'teal' },
];
return (
<section className="rounded-2xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.07] via-dark-900 to-purple-500/[0.06] p-6" aria-labelledby="ia-workflow-title">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300"><Workflow className="h-5 w-5" aria-hidden="true" /></div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-cyan-400">Parcours applicatif</p>
<h2 id="ia-workflow-title" className="mt-1 text-xl font-bold text-white">Quel moteur IA utilise chaque application ?</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-300">Le Référentiel IA décide et protège le parcours ; le Moteur IA local exécute les demandes privées sur Ollama lorsque ce mode est retenu.</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300"><ShieldCheck className="h-4 w-4" aria-hidden="true" />Aucune clé fournisseur ni URL Ollama privée</div>
</div>
<div className="mt-7 flex flex-col items-stretch gap-3 lg:flex-row lg:items-center lg:justify-center">
<div className="w-full max-w-sm lg:w-72"><WorkflowNode icon={Bot} eyebrow="1 · demande" title="Application cliente" description="Envoie sa demande à la passerelle du Référentiel IA." tone="blue" /></div>
<ArrowRight className="mx-auto h-5 w-5 rotate-90 text-cyan-400 lg:rotate-0" aria-hidden="true" />
<div className="w-full max-w-sm lg:w-80"><WorkflowNode icon={BrainCircuit} eyebrow="2 · décision" title="Référentiel IA Santinova" description="Applique le choix, les autorisations, les budgets et la traçabilité." tone="purple" /></div>
</div>
<div className="mx-auto flex h-8 w-px items-center justify-center bg-gradient-to-b from-purple-400 to-cyan-400" aria-hidden="true"><ArrowDown className="mt-6 h-4 w-4 text-cyan-300" /></div>
<div className="grid gap-3 md:grid-cols-3" role="list" aria-label="Modes de décision du moteur IA">
{modes.map((mode) => <div key={mode.title} role="listitem"><WorkflowNode icon={mode.icon} eyebrow="3 · mode de décision" title={mode.title} description={mode.description} tone={mode.tone} /></div>)}
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_auto_1fr] lg:items-stretch">
<WorkflowNode icon={ServerCog} eyebrow="4a · exécution privée" title="Moteur IA local" description="Le service exécute localement sur Ollama. Les requêtes et jetons locaux disposent de plafonds dédiés." tone="teal" />
<div className="flex items-center justify-center" aria-hidden="true"><ArrowRight className="h-5 w-5 rotate-90 text-gray-500 lg:rotate-0" /></div>
<WorkflowNode icon={Globe} eyebrow="4b · exécution externe" title="Moteur externe autorisé" description="La passerelle appelle exclusivement le fournisseur prévu par la décision ou le choix manuel." tone="emerald" />
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr_1.35fr]">
<div className="flex gap-3 rounded-xl border border-amber-500/25 bg-amber-500/[0.08] p-4"><CircleAlert className="h-5 w-5 flex-shrink-0 text-amber-400" aria-hidden="true" /><div><p className="text-sm font-semibold text-amber-200">Basculement externe autorisé</p><p className="mt-1 text-xs leading-5 text-gray-300">Uniquement si le moteur local est indisponible, si l’option est activée et vers le moteur externe autorisé choisi par l’administrateur.</p></div></div>
<div className="flex gap-3 rounded-xl border border-dark-700 bg-dark-950/40 p-4"><LockKeyhole className="h-5 w-5 flex-shrink-0 text-cyan-300" aria-hidden="true" /><div><p className="text-sm font-semibold text-white">Plafonds séparés et traces de décision</p><p className="mt-1 text-xs leading-5 text-gray-400">Les consommations locales et externes restent séparées. Une limite locale atteinte bloque l’appel local sans déclencher de repli.</p></div></div>
</div>
</section>
);
}
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Page principale Page principale
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
@@ -267,7 +361,7 @@ export default function DocumentationPage() {
</div> </div>
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20"> <div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<Clock className="w-3.5 h-3.5 text-emerald-400" /> <Clock className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span> <span className="text-xs text-emerald-400">Mis à jour le 05 Septembre 2026</span>
</div> </div>
</div> </div>
@@ -300,6 +394,8 @@ export default function DocumentationPage() {
)} )}
</div> </div>
<AiDecisionWorkflow />
{/* ── Grille Recette / CI-CD / Production ── */} {/* ── Grille Recette / CI-CD / Production ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">

View File

@@ -1,56 +1,55 @@
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 = [
{ {
id: 'itinova-contacts', id: 'itinova-contacts',
name: 'Itinova Contacts', name: 'Itinova Contacts',
repoName: 'itinova-contacts', repoName: 'itinova-contacts',
urlRecette: 'https://contacts.recette.santinova-soft.org', urlRecette: 'https://contacts-itinova.recette.santinova-soft.org',
urlProd: 'https://contacts.santinova-soft.org', urlProd: 'https://contacts-itinova.santinova-soft.org',
}, },
{ {
id: 'itinova-podcasts', id: 'itinova-podcasts',
name: 'Itinova Podcasts', name: 'Itinova Podcasts',
repoName: 'itinova-podcasts', repoName: 'itinova-podcasts',
urlRecette: 'https://podcasts.recette.santinova-soft.org', urlRecette: 'https://podcasts-itinova.recette.santinova-soft.org',
urlProd: 'https://podcasts.santinova-soft.org', urlProd: 'https://podcasts-itinova.santinova-soft.org',
}, },
{ {
id: 'veille-reglementaire', id: 'veille-reglementaire',
name: 'Veille Réglementaire', name: 'Veille Réglementaire',
repoName: 'veille-reglementaire', repoName: 'veille-reglementaire',
urlRecette: 'https://veille.recette.santinova-soft.org', urlRecette: 'https://veille-itinova.recette.santinova-soft.org',
urlProd: 'https://veille.santinova-soft.org', urlProd: 'https://veille-itinova.santinova-soft.org',
}, },
{ {
id: 'itinova-vehicle-exchange', id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte', name: 'Itinova Gestion de Flotte',
repoName: 'itinova-vehicle-exchange', repoName: 'itinova-vehicle-exchange',
urlRecette: 'https://flotte.recette.santinova-soft.org', urlRecette: 'https://flotte-itinova.recette.santinova-soft.org',
urlProd: 'https://flotte.santinova-soft.org', urlProd: 'https://flotte-itinova.santinova-soft.org',
}, },
{ {
id: 'sonum', id: 'sonum',
name: 'SONUM', name: 'SONUM',
repoName: 'sonum', repoName: 'sonum',
urlRecette: 'https://sonum.recette.santinova-soft.org', urlRecette: 'https://sonum-santinova.recette.santinova-soft.org',
urlProd: 'https://sonum.santinova-soft.org', urlProd: 'https://sonum-santinova.santinova-soft.org',
}, },
{ {
id: 'demat-facturation-dsi', id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI', name: 'Démat. Facturation DSI',
repoName: 'demat-facturation-dsi', repoName: 'demat-facturation-dsi',
urlRecette: 'https://demat-facturation.recette.santinova-soft.org', urlRecette: 'https://demat-facturation-santinova.recette.santinova-soft.org',
urlProd: 'https://demat-facturation.santinova-soft.org', urlProd: 'https://demat-facturation-santinova.santinova-soft.org',
}, },
{ {
id: 'facturation-santinova', id: 'facturation-santinova',
name: 'Facturation Santinova', name: 'Facturation Santinova',
repoName: 'facturation-santinova', repoName: 'facturation-santinova',
urlRecette: 'https://facturation.recette.santinova-soft.org', urlRecette: 'https://facturation-santinova.recette.santinova-soft.org',
urlProd: null, urlProd: null,
}, },
{ {
@@ -58,42 +57,42 @@ const APPS_CONFIG = [
name: 'Formation Manager', name: 'Formation Manager',
repoName: 'formation-manager-itinova', repoName: 'formation-manager-itinova',
urlRecette: 'https://formation.recette.santinova-soft.org', urlRecette: 'https://formation.recette.santinova-soft.org',
urlProd: 'https://formations.itinova.org', urlProd: 'https://formations-itinova.santinova-soft.org',
}, },
{ {
id: 'pilotage-masse-salariale', id: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale', name: 'Pilotage Masse Salariale',
repoName: 'pilotage-masse-salariale', repoName: 'pilotage-masse-salariale',
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org', urlRecette: 'https://pilotage-ms-itinova.recette.santinova-soft.org',
urlProd: null, urlProd: null,
}, },
{ {
id: 'itinova-budget-si', id: 'itinova-budget-si',
name: 'Gestion Budget Informatique', name: 'Gestion Budget Informatique',
repoName: 'itinova-budget-si', repoName: 'itinova-budget-si',
urlRecette: 'https://budget-si.recette.santinova-soft.org', urlRecette: 'https://budget-si-santinova.recette.santinova-soft.org',
urlProd: null, urlProd: null,
}, },
{ {
id: 'falc-generator', id: 'falc-generator',
name: 'FALC Generator', name: 'FALC Generator',
repoName: 'falc-generator', repoName: 'falc-generator',
urlRecette: 'https://falc.recette.santinova-soft.org', urlRecette: 'https://falc-itinova.recette.santinova-soft.org',
urlProd: 'https://falc.santinova-soft.org', urlProd: 'https://falc-itinova.santinova-soft.org',
}, },
{ {
id: 'portail-santinova', id: 'portail-santinova',
name: 'Portail Applicatif', name: 'Portail Applicatif',
repoName: 'portail-santinova', repoName: 'portail-santinova',
urlRecette: 'https://portail.recette.santinova-soft.org', urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
urlProd: 'https://portail.santinova-soft.org', urlProd: 'https://portail-santinova.santinova-soft.org',
}, },
{ {
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-santinova.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org', urlProd: 'https://dashboard-santinova.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');

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
const sourcePath = new URL('../src/pages/DocumentationPage.jsx', import.meta.url);
test('la documentation présente les deux applications IA et les trois modes de décision', async () => {
const source = await readFile(sourcePath, 'utf8');
assert.match(source, /Référentiel IA Santinova/);
assert.match(source, /Moteur IA local/);
assert.match(source, /Recommandation/);
assert.match(source, /Choix manuel/);
assert.match(source, /Moteur IA local/);
assert.match(source, /Basculement externe autorisé/);
});
test('le workflow rappelle la séparation des budgets et des accès internes', async () => {
const source = await readFile(sourcePath, 'utf8');
assert.match(source, /Plafonds séparés/);
assert.match(source, /Aucune clé fournisseur ni URL Ollama privée/);
assert.match(source, /aria-labelledby="ia-workflow-title"/);
assert.match(source, /aria-label="Modes de décision du moteur IA"/);
assert.match(source, /referentiel-ia-santinova\.recette\.santinova-soft\.org/);
assert.match(source, /llm-local\.recette\.santinova-soft\.org/);
assert.doesNotMatch(source, /http:\/\/ollama:11434/);
});

View File

@@ -4,7 +4,7 @@ import test from 'node:test';
import { getEnvironmentConfig } from '../src/utils/environment.js'; import { getEnvironmentConfig } from '../src/utils/environment.js';
test('détecte le dashboard de recette depuis son domaine public', () => { test('détecte le dashboard de recette depuis son domaine public', () => {
const config = getEnvironmentConfig('dashboard.recette.santinova-soft.org'); const config = getEnvironmentConfig('dashboard-santinova.recette.santinova-soft.org');
assert.equal(config.isRecette, true); assert.equal(config.isRecette, true);
assert.equal(config.label, 'Recette'); assert.equal(config.label, 'Recette');
assert.equal(config.serverPublicIp, '78.138.58.109'); assert.equal(config.serverPublicIp, '78.138.58.109');
@@ -13,7 +13,7 @@ test('détecte le dashboard de recette depuis son domaine public', () => {
}); });
test('détecte le dashboard de production depuis son domaine public', () => { test('détecte le dashboard de production depuis son domaine public', () => {
const config = getEnvironmentConfig('dashboard.santinova-soft.org'); const config = getEnvironmentConfig('dashboard-santinova.santinova-soft.org');
assert.equal(config.isRecette, false); assert.equal(config.isRecette, false);
assert.equal(config.label, 'Production'); assert.equal(config.label, 'Production');
assert.equal(config.serverPublicIp, '180.149.196.138'); assert.equal(config.serverPublicIp, '180.149.196.138');