Compare commits

...

59 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
1c9a9eff8f fix: nommer le dashboard selon l environnement 2026-09-05 02:38:02 +00:00
2d92846f04 chore: intégrer les références système production 2026-09-05 02:37:53 +00:00
428e614c87 fix: adapter l inventaire à l environnement courant 2026-09-05 02:35:02 +00:00
69b5ab7462 chore: intégrer le correctif de libellé production 2026-09-05 02:34:35 +00:00
9b75964c29 chore: déclencher la validation du commit commun 2026-09-05 02:32:14 +00:00
6dd9a09a58 chore: réunifier les historiques dashboard recette et production 2026-09-05 02:28:52 +00:00
0572c4dff6 fix: partager le même commit entre recette et production 2026-09-05 02:25:03 +00:00
Manus Déploiement Santinova
9289011b3c feat: exposer les services infra dans le portail 2026-09-04 15:57:37 +00:00
Manus Déploiement Santinova
9867adbcc2 feat: placer les services infra en tête des listes 2026-09-04 15:51:49 +00:00
Manus Déploiement Santinova
4a2be26a10 fix: préserver la catégorie dans les statuts applicatifs 2026-09-04 15:37:19 +00:00
Manus Déploiement Santinova
5c3d644ab8 Ajouter le dispatcher e-mail des alertes CI 2026-08-21 20:28:13 +00:00
Manus Déploiement Santinova
c693da6273 Ignorer les demandes de déploiement hôte 2026-08-21 20:27:18 +00:00
Manus Déploiement Santinova
c0944cfb13 Corriger la préparation de rotation Gitea 2026-08-21 16:50:17 +00:00
Manus Déploiement Santinova
06fad063a4 Préserver le tableau de bord CI existant 2026-08-21 16:31:13 +00:00
Manus Déploiement Santinova
a37e29e42a Sécuriser Gitea et alerter sur les échecs CI 2026-08-21 16:16:46 +00:00
Santinova Dashboard Automation
1f1d525ea1 Merge branch 'ci-trends' 2026-08-20 17:56:30 +02:00
Manus AI
3da5fd104c feat(ci): afficher tendances et médianes des workflows 2026-08-20 15:52:56 +00:00
Santinova Dashboard Automation
2991c7f068 feat(dashboard): distinguer visuellement les apps INFRA dans le tableau de bord 2026-08-20 16:32:37 +02:00
Manus AI
37f699fcc2 fix(ci): lire les horodatages Gitea 1.25 2026-08-18 17:48:18 +00:00
Manus AI
3eae610c5e fix(deploy): construire le dashboard depuis les sources versionnées 2026-08-18 17:38:13 +00:00
Manus AI
57ef1785aa feat(ci): conditionner les déploiements et exposer les métriques 2026-08-18 17:12:57 +00:00
Manus AI
cbc8eee818 fix(ci): utiliser le binaire tar Alpine sans lien circulaire 2026-08-18 12:54:21 +00:00
Manus AI
666e0be2a2 fix(ci): inclure GNU tar pour le cache pnpm 2026-08-18 12:50:48 +00:00
Manus AI
35206fb14b perf(ci): ajouter cache et image Node 22 au runner recette 2026-08-18 12:33:40 +00:00
Manus AI
d553cc4278 chore(ops): versionner les scripts de supervision comme exécutables 2026-08-18 09:00:05 +00:00
Manus AI
5105123ec4 fix(deploy): déléguer le redéploiement dashboard recette à l hôte 2026-08-18 08:59:23 +00:00
Manus AI
87cc629d81 fix(ci): fournir bash au runner recette local 2026-08-18 08:49:16 +00:00
Manus AI
d8b24f8968 chore(ci): versionner le script d installation du runner comme exécutable 2026-08-18 08:39:26 +00:00
Manus AI
f692be04d2 fix(ci): utiliser une image runner locale compatible 2026-08-18 08:38:17 +00:00
Manus AI
7270e1a89a fix(ci): migrer l enregistrement runner existant 2026-08-18 08:32:45 +00:00
Manus AI
7defcabdc3 fix(ci): conserver l enregistrement du runner Gitea 2026-08-18 08:32:17 +00:00
Manus AI
b006caa3df ci(ops): ajouter runner Gitea recette versionné 2026-08-18 08:30:37 +00:00
Santinova Dashboard Automation
64f3233e47 Merge remote-tracking branch 'origin/main' 2026-08-18 10:06:53 +02:00
Manus AI
bf032cc26a fix(ops): réduire les faux positifs de supervision 2026-08-18 08:04:56 +00:00
Manus AI
392782a0d2 fix(ops): ne pas bloquer l installation sur une alerte 2026-08-18 07:59:53 +00:00
Manus AI
b44cdd5f11 fix(ops): rendre le healthcheck non intrusif 2026-08-18 07:59:27 +00:00
Manus
e168f86578 fix: adapter le webhook au déploiement par environnement 2026-08-18 07:34:15 +00:00
Manus AI
5e8d4796d4 feat(ops): ajouter supervision automatique des applications 2026-08-18 07:31:02 +00:00
Manus
9500ca2d70 refactor: app.json comme source de vérité du dashboard 2026-08-17 20:20:44 +00:00
e451892860 fix(portail): renommer Gestion des AT 2026-07-30 09:51:53 +02:00
Santinova Dashboard Automation
5776b15bb5 merge: intégrer le dashboard recette refondu et les améliorations robustes 2026-07-24 16:08:39 +02:00
Santinova Dashboard Automation
f412839e6e backup: préserver les améliorations robustes de recette 2026-07-24 16:04:15 +02:00
Manus Deploy
6f9ee7fc5c fix: config.js - pilotage image, portail category INFRA, demat image jpg 2026-07-24 13:49:18 +00:00
Manus Deploy
fe6ab4a2ff fix: startContainer crée le conteneur via docker compose up si inexistant 2026-07-24 13:37:10 +00:00
Manus Deploy
03a6d55571 feat: scan automatique des app.json au démarrage (découverte FALC et autres) 2026-07-24 13:15:43 +00:00
Manus Deploy
7a7b9fde47 feat: découverte dynamique apps - portail dynamique - fix URLs 2026-07-24 13:04:21 +00:00
Manus AI
120567a6e5 fix: version webhook deployment flow 2026-07-24 12:32:43 +00:00
Manus AI
9d1db4d035 feat: register gestion at v2 dashboard deployment 2026-07-24 12:12:43 +00:00
Manus
eb07a99e5e fix: /public/apps ne bloque plus sur getAllStatuses (async -> sync) 2026-07-24 10:40:45 +00:00
Manus
85bd5209c7 feat: découverte dynamique des apps via app.json + endpoint /public/apps 2026-07-24 10:25:18 +00:00
35 changed files with 810 additions and 110 deletions

View File

@@ -22,4 +22,5 @@ jobs:
working-directory: src/frontend
run: |
npm ci
npm test
npm run build

View File

@@ -19,9 +19,21 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac
"giteaRepo": "itinova-contacts",
"giteaOwner": "manus-admin",
"image": "images/itinova-contacts.png",
"ai": {
"engines": [
{
"provider": "Google Gemini",
"model": "gemini-2.5-flash",
"role": "Assistant conversationnel",
"billing": "paid",
"source": "application"
}
],
"activeConfigurationPath": "/api/ai/active-configuration"
},
"urls": {
"recette": "https://contacts.recette.santinova-soft.org",
"prod": "https://contacts.santinova-soft.org"
"recette": "https://contacts-itinova.recette.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. |
| `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. |
| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. |
| `ai.engines` | Moteurs IA déclarés | Tableau optionnel de métadonnées publiques : `provider`, `model`, `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.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. |
## Règles de déploiement
Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard.
Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. Lorsque l’application utilise un moteur IA, le même commit renseigne `ai.engines` avec des informations descriptives vérifiées, jamais avec des secrets. `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.

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
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
# Copy frontend package files and source from the versioned src tree.
COPY src/frontend/package.json src/frontend/pnpm-lock.yaml* ./
RUN npm install
# Copy frontend source
COPY frontend/ ./
# Build frontend
COPY src/frontend/ ./
RUN npm run build
# Stage 2: Production
# Stage 2: Production runtime
FROM node:20-alpine
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
# Configure git global identity to avoid "Author identity unknown" errors
RUN git config --global user.email "admin@santinova-soft.org" && \
git config --global user.name "Manus Dashboard"
# Copy backend
COPY backend/package.json ./backend/
# Copy backend dependencies and source from the versioned src tree.
COPY src/backend/package.json src/backend/package-lock.json ./backend/
WORKDIR /app/backend
RUN npm install --production
RUN npm ci --omit=dev
COPY src/backend/ ./
COPY backend/ ./
# Copy built frontend
# Copy the frontend built from the same Git revision.
WORKDIR /app
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
WORKDIR /app/backend
EXPOSE 3001
CMD ["node", "src/index.js"]

View File

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

View File

@@ -3,8 +3,8 @@
"name": "Dashboard Production",
"category": "INFRA",
"urls": {
"recette": "https://dashboard.recette.santinova-soft.org",
"prod": "https://dashboard.santinova-soft.org"
"recette": "https://dashboard-santinova.recette.santinova-soft.org",
"prod": "https://dashboard-santinova.santinova-soft.org"
},
"containerName": "manus-dashboard",
"image": "images/dashboard.png",

View File

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

View File

@@ -36,7 +36,7 @@ services:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.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.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"

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

View File

@@ -84,8 +84,8 @@ const APPS_CONFIG = [
id: 'portail-santinova',
name: 'Portail Applicatif',
repoName: 'portail-santinova',
urlRecette: 'https://portail.recette.santinova-soft.org',
urlProd: 'https://portail.santinova-soft.org',
urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
urlProd: 'https://portail-santinova.santinova-soft.org',
},
{
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_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}"
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia-santinova.recette.santinova-soft.org}"
backup="$(mktemp /tmp/manus-dashboard-env-backup.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_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"))
ENV_FILE = DASHBOARD_APP_DIR / ".env"
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
- URL : https://dashboard.santinova-soft.org
- URL : https://dashboard-santinova.santinova-soft.org
- Login : adminItinova / Itinova69!

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js"
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/healthcheck-category.test.js && node test/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": {
"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 path = require('path');
const { getAiCatalogEntry } = require('./ai-catalog');
const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
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) {
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.
* Les champs dérivés (directory et healthCheckUrl) ne sont pas écrits dans le
@@ -51,9 +112,15 @@ function normalizeManifest(manifest, directory, environment) {
throw new Error('ci doit être un objet avec une propriété required booléenne');
}
const displayName = manifest.id === 'manus-dashboard'
? `Dashboard ${environment === 'prod' ? 'Production' : 'Recette'}`
: manifest.name.trim();
const ai = normalizeAi(manifest.ai, manifest.id);
return {
...manifest,
name: manifest.name.trim(),
name: displayName,
ai,
directory,
containerName: manifest.containerName || manifest.id,
healthCheckUrl: manifest.urls[environment],
@@ -105,13 +172,14 @@ function discoverApps({ appsBasePath, environment }) {
* Toute valeur obsolète est remplacée : seules les applications réellement
* 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({
appsBasePath: config.appsBasePath,
environment: config.environment,
});
config.apps.splice(0, config.apps.length, ...result.apps);
config.appsRefreshedAt = now;
for (const issue of result.errors) {
logger.warn(`[app-registry] Manifeste ignoré (${issue.directory}) : ${issue.error}`);
}
@@ -120,6 +188,24 @@ function refreshApps(config, logger = console) {
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.
*/
@@ -150,6 +236,7 @@ function findAppByRepo(config, repositoryName) {
module.exports = {
discoverApps,
refreshApps,
refreshAppsIfStale,
writeAppManifest,
findAppDirectoryByRepo,
findAppByRepo,

View File

@@ -31,7 +31,7 @@ module.exports = {
},
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,
email: process.env.DSI_ALERT_EMAIL || null,
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.
* Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite
@@ -63,8 +98,9 @@ async function checkApp(appConfig) {
id: appConfig.id,
name: appConfig.name,
description: appConfig.description || '',
// Préserve la catégorie du manifeste pour le rendu différencié des services INFRA.
// Préserve la catégorie et les métadonnées IA du manifeste pour l’interface.
category: appConfig.category || 'SANTINOVA',
ai: appConfig.ai || { engines: [] },
urls: appConfig.urls,
containerName: appConfig.containerName,
lastCheck: new Date().toISOString(),
@@ -73,11 +109,18 @@ async function checkApp(appConfig) {
health: { recette: null, prod: null },
};
const [containerInfo, health] = await Promise.all([
const [containerInfo, health, activeAiConfiguration] = await Promise.all([
getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
getActiveAiConfiguration(appConfig, localUrl),
]);
if (activeAiConfiguration) {
result.ai = {
...appConfig.ai,
...activeAiConfiguration,
};
}
result.container = containerInfo;
result.health[environment] = health;
@@ -162,6 +205,7 @@ function getAllStatuses() {
module.exports = {
checkUrl,
getActiveAiConfiguration,
checkApp,
checkAllApps,
mapWithConcurrency,

View File

@@ -10,12 +10,24 @@ const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs,
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const { readCiAlerts } = require('./ci-alerts');
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)
cleanupOrphanedDeployments();
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
const uploadDir = '/tmp/dashboard-uploads';
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 path = require('node:path');
const { discoverApps, refreshApps } = require('../src/app-registry');
const { discoverApps, refreshApps, refreshAppsIfStale } = require('../src/app-registry');
function writeManifest(basePath, directory, manifest) {
const appPath = path.join(basePath, directory);
@@ -24,10 +24,31 @@ try {
category: 'ITINOVA',
containerName: 'itinova-contacts',
image: 'images/itinova-contacts.png',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
ai: {
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }],
activeConfigurationPath: '/api/ai/active-configuration',
},
urls: {
recette: 'https://contacts-itinova.recette.santinova-soft.org',
prod: 'https://contacts-itinova.santinova-soft.org',
},
});
writeManifest(sandbox, 'dashboard', {
id: 'manus-dashboard',
name: 'Dashboard',
category: 'INFRA',
urls: {
recette: 'https://dashboard-santinova.recette.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', {
@@ -36,15 +57,48 @@ try {
category: 'INCONNUE',
urls: { recette: 'https://invalide.recette.santinova-soft.org' },
});
writeManifest(sandbox, 'ia-invalide', {
id: 'app-ia-invalide',
name: 'Application IA invalide',
category: 'ITINOVA',
ai: { engines: [{ provider: 'Google Gemini' }] },
urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' },
});
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' });
assert.equal(discovery.apps.length, 1, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée');
assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 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[0];
const app = discovery.apps.find((candidate) => candidate.id === 'itinova-contacts');
assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste');
assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant');
assert.equal(app.containerName, 'itinova-contacts');
assert.deepEqual(app.ai, {
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', 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');
assert.equal(dashboardRecette.name, 'Dashboard Recette');
const productionDiscovery = discoverApps({ appsBasePath: sandbox, environment: 'prod' });
const dashboardProduction = productionDiscovery.apps.find((candidate) => candidate.id === 'manus-dashboard');
assert.equal(dashboardProduction.name, 'Dashboard Production');
const config = {
appsBasePath: sandbox,
@@ -52,8 +106,39 @@ try {
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
};
const refresh = refreshApps(config);
assert.equal(refresh.apps.length, 1, 'les applications non déployées ne doivent pas rester en cache');
assert.equal(config.apps[0].name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique');
assert.equal(refresh.apps.length, 3, 'les applications non déployées ne doivent pas rester en cache');
assert.equal(
config.apps.find((candidate) => candidate.id === 'itinova-contacts').name,
'Itinova Contacts',
'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');
} finally {

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',
name: 'Référentiel IA Santinova',
category: 'INFRA',
ai: { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] },
urls: {},
containerName: 'referentiel-ia',
});
assert.equal(result.category, 'INFRA');
assert.deepEqual(result.ai, { engines: [{ provider: 'Ollama', model: 'llama3.2:1b' }] });
console.log('healthcheck-category: OK');
})().catch((error) => {
console.error(error);

View File

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

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"test": "node --test test/*.test.mjs",
"build": "vite build",
"preview": "vite preview"
},

View File

@@ -14,6 +14,7 @@ import {
ChevronDown,
} from 'lucide-react';
import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api';
import { APP_DOMAIN_SUFFIX, ENVIRONMENT_LABEL_LOWER } from '../utils/environment';
const STEPS = [
{ id: 1, title: 'Informations', icon: FileText },
@@ -180,7 +181,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
Ajouter une application
</h2>
<p className="text-sm text-gray-400 mt-0.5">
Déployez une nouvelle application sur le serveur de recette
Déployez une nouvelle application sur le serveur de {ENVIRONMENT_LABEL_LOWER}
</p>
</div>
<button
@@ -271,7 +272,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
}`}
/>
<span className="px-3 py-2.5 bg-dark-600 border border-dark-500 border-l-0 rounded-r-lg text-gray-400 text-sm whitespace-nowrap">
.recette.santinova-soft.org
.{APP_DOMAIN_SUFFIX}
</span>
</div>
{errors.subdomain && (
@@ -280,7 +281,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
{formData.subdomain && !errors.subdomain && (
<p className="text-gray-500 text-xs mt-1 flex items-center gap-1">
<Globe className="w-3 h-3" />
URL: https://{formData.subdomain}.recette.santinova-soft.org
URL: https://{formData.subdomain}.{APP_DOMAIN_SUFFIX}
</p>
)}
</div>
@@ -545,7 +546,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
L'application est en cours de déploiement. Suivez la progression dans l'onglet Déploiements.
</p>
<p className="text-gray-500 mt-2 text-xs">
URL: https://{formData.subdomain}.recette.santinova-soft.org
URL: https://{formData.subdomain}.{APP_DOMAIN_SUFFIX}
</p>
</div>
) : (
@@ -573,7 +574,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">URL</span>
<span className="text-primary-400 text-sm">
https://{formData.subdomain}.recette.santinova-soft.org
https://{formData.subdomain}.{APP_DOMAIN_SUFFIX}
</span>
</div>
<div className="flex justify-between items-center px-4 py-3">

View File

@@ -118,8 +118,9 @@ export default function AppCard({ app, commits, onRefresh }) {
const latestCommit = commits && commits.length > 0 ? commits[0] : null;
// Apps d'infrastructure : portail et dashboard
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
// Le manifeste est la seule source de vérité pour le type et les moteurs IA.
const isInfra = app.category === 'INFRA';
const aiEngines = Array.isArray(app.ai?.engines) ? app.ai.engines : [];
return (
<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 */}
<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 */}
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">

View File

@@ -19,6 +19,16 @@ import {
Clock,
Zap,
Package,
Bot,
BrainCircuit,
Workflow,
ShieldCheck,
Route,
ArrowDown,
CircleAlert,
LockKeyhole,
ServerCog,
Sparkles,
} from 'lucide-react';
/* ─────────────────────────────────────────────
@@ -45,17 +55,17 @@ const RECETTE = {
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.recette.santinova-soft.org',
url: 'dashboard-santinova.recette.santinova-soft.org',
desc: 'Monitoring CPU / RAM / Disque',
href: 'https://dashboard.recette.santinova-soft.org',
href: 'https://dashboard-santinova.recette.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.recette.santinova-soft.org',
url: 'portail-santinova.recette.santinova-soft.org',
desc: 'Vitrine centralisée des applications',
href: 'https://portail.recette.santinova-soft.org',
href: 'https://portail-santinova.recette.santinova-soft.org',
},
{
icon: Shield,
@@ -70,44 +80,60 @@ const RECETTE = {
icon: '🧾',
color: 'green',
name: 'Démat. Facturation DSI',
url: 'demat-facturation.recette.santinova-soft.org',
href: 'https://demat-facturation.recette.santinova-soft.org',
url: 'demat-facturation-santinova.recette.santinova-soft.org',
href: 'https://demat-facturation-santinova.recette.santinova-soft.org',
},
{
icon: '👁️',
color: 'indigo',
name: 'Veille Réglementaire',
url: 'veille.recette.santinova-soft.org',
href: 'https://veille.recette.santinova-soft.org',
url: 'veille-itinova.recette.santinova-soft.org',
href: 'https://veille-itinova.recette.santinova-soft.org',
},
{
icon: '🗺️',
color: 'cyan',
name: 'SONUM',
url: 'sonum.recette.santinova-soft.org',
url: 'sonum-santinova.recette.santinova-soft.org',
desc: 'Cartographie solutions numériques FEHAP',
href: 'https://sonum.recette.santinova-soft.org',
href: 'https://sonum-santinova.recette.santinova-soft.org',
},
{
icon: '👤',
color: 'pink',
name: 'Itinova Contacts',
url: 'contacts.recette.santinova-soft.org',
href: 'https://contacts.recette.santinova-soft.org',
url: 'contacts-itinova.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: '🚗',
color: 'yellow',
name: 'Flotte Véhicules',
url: 'flotte.recette.santinova-soft.org',
href: 'https://flotte.recette.santinova-soft.org',
url: 'flotte-itinova.recette.santinova-soft.org',
href: 'https://flotte-itinova.recette.santinova-soft.org',
},
{
icon: '🎧',
color: 'orange',
name: 'Podcasts Itinova',
url: 'podcasts.recette.santinova-soft.org',
href: 'https://podcasts.recette.santinova-soft.org',
url: 'podcasts-itinova.recette.santinova-soft.org',
href: 'https://podcasts-itinova.recette.santinova-soft.org',
},
],
};
@@ -133,17 +159,17 @@ const PRODUCTION = {
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.santinova-soft.org',
url: 'dashboard-santinova.santinova-soft.org',
desc: 'Monitoring temps réel',
href: 'https://dashboard.santinova-soft.org',
href: 'https://dashboard-santinova.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.santinova-soft.org',
url: 'portail-santinova.santinova-soft.org',
desc: 'Vitrine applicative production',
href: 'https://portail.santinova-soft.org',
href: 'https://portail-santinova.santinova-soft.org',
},
{
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>;
}
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
───────────────────────────────────────────── */
@@ -267,7 +361,7 @@ export default function DocumentationPage() {
</div>
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<Clock className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span>
<span className="text-xs text-emerald-400">Mis à jour le 05 Septembre 2026</span>
</div>
</div>
@@ -300,6 +394,8 @@ export default function DocumentationPage() {
)}
</div>
<AiDecisionWorkflow />
{/* ── Grille Recette / CI-CD / Production ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">

View File

@@ -19,6 +19,7 @@ import {
ShieldCheck,
} from 'lucide-react';
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
import { GITEA_HOST, GITEA_URL } from '../utils/environment';
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
@@ -137,12 +138,12 @@ export default function GiteaPage() {
<p className="text-gray-400 mt-1">
Explorez les dépôts sur{' '}
<a
href="https://git.recette.santinova-soft.org"
href={GITEA_URL}
target="_blank"
rel="noopener noreferrer"
className="text-primary-400 hover:text-primary-300"
>
git.recette.santinova-soft.org
{GITEA_HOST}
</a>
</p>
</div>

View File

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

View File

@@ -0,0 +1,38 @@
/**
* Identité de l'environnement courant, déterminée par le domaine public.
* Le même commit peut ainsi être construit et exécuté en recette comme en
* production, les secrets et domaines restant fournis par chaque serveur.
*/
export function getEnvironmentConfig(hostname) {
const normalizedHostname = hostname.toLowerCase();
const isRecette =
normalizedHostname.includes('recette') ||
normalizedHostname === 'localhost' ||
normalizedHostname === '127.0.0.1';
return {
isRecette,
label: isRecette ? 'Recette' : 'Production',
labelLower: isRecette ? 'recette' : 'production',
serverPublicIp: isRecette ? '78.138.58.109' : '180.149.196.138',
giteaUrl: isRecette
? 'https://git.recette.santinova-soft.org'
: 'https://git.santinova-soft.org',
appDomainSuffix: isRecette
? 'recette.santinova-soft.org'
: 'santinova-soft.org',
defaultSshPreset: isRecette ? 'recette' : 'production',
};
}
const currentHostname = typeof window === 'undefined' ? 'localhost' : window.location.hostname;
const environment = getEnvironmentConfig(currentHostname);
export const IS_RECETTE = environment.isRecette;
export const ENVIRONMENT_LABEL = environment.label;
export const ENVIRONMENT_LABEL_LOWER = environment.labelLower;
export const SERVER_PUBLIC_IP = environment.serverPublicIp;
export const GITEA_URL = environment.giteaUrl;
export const GITEA_HOST = new URL(GITEA_URL).hostname;
export const APP_DOMAIN_SUFFIX = environment.appDomainSuffix;
export const DEFAULT_SSH_PRESET = environment.defaultSshPreset;

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

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getEnvironmentConfig } from '../src/utils/environment.js';
test('détecte le dashboard de recette depuis son domaine public', () => {
const config = getEnvironmentConfig('dashboard-santinova.recette.santinova-soft.org');
assert.equal(config.isRecette, true);
assert.equal(config.label, 'Recette');
assert.equal(config.serverPublicIp, '78.138.58.109');
assert.equal(config.giteaUrl, 'https://git.recette.santinova-soft.org');
assert.equal(config.appDomainSuffix, 'recette.santinova-soft.org');
});
test('détecte le dashboard de production depuis son domaine public', () => {
const config = getEnvironmentConfig('dashboard-santinova.santinova-soft.org');
assert.equal(config.isRecette, false);
assert.equal(config.label, 'Production');
assert.equal(config.serverPublicIp, '180.149.196.138');
assert.equal(config.giteaUrl, 'https://git.santinova-soft.org');
assert.equal(config.appDomainSuffix, 'santinova-soft.org');
});
test('utilise la recette pour le développement local', () => {
assert.equal(getEnvironmentConfig('localhost').isRecette, true);
assert.equal(getEnvironmentConfig('127.0.0.1').isRecette, true);
});