Compare commits
59 Commits
889af27766
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1558b3b6 | ||
|
|
575d749c52 | ||
|
|
a6c1dab3d7 | ||
|
|
9fd5dbb0df | ||
|
|
3c000c37af | ||
|
|
df1dd3d15b | ||
|
|
85a1415e05 | ||
|
|
7d7006ab29 | ||
|
|
692762674b | ||
| 1c9a9eff8f | |||
| 2d92846f04 | |||
| 428e614c87 | |||
| 69b5ab7462 | |||
| 9b75964c29 | |||
| 6dd9a09a58 | |||
| 0572c4dff6 | |||
|
|
9289011b3c | ||
|
|
9867adbcc2 | ||
|
|
4a2be26a10 | ||
|
|
5c3d644ab8 | ||
|
|
c693da6273 | ||
|
|
c0944cfb13 | ||
|
|
06fad063a4 | ||
|
|
a37e29e42a | ||
|
|
1f1d525ea1 | ||
|
|
3da5fd104c | ||
|
|
2991c7f068 | ||
|
|
37f699fcc2 | ||
|
|
3eae610c5e | ||
|
|
57ef1785aa | ||
|
|
cbc8eee818 | ||
|
|
666e0be2a2 | ||
|
|
35206fb14b | ||
|
|
d553cc4278 | ||
|
|
5105123ec4 | ||
|
|
87cc629d81 | ||
|
|
d8b24f8968 | ||
|
|
f692be04d2 | ||
|
|
7270e1a89a | ||
|
|
7defcabdc3 | ||
|
|
b006caa3df | ||
|
|
64f3233e47 | ||
|
|
bf032cc26a | ||
|
|
392782a0d2 | ||
|
|
b44cdd5f11 | ||
|
|
e168f86578 | ||
|
|
5e8d4796d4 | ||
|
|
9500ca2d70 | ||
| e451892860 | |||
|
|
5776b15bb5 | ||
|
|
f412839e6e | ||
|
|
6f9ee7fc5c | ||
|
|
fe6ab4a2ff | ||
|
|
03a6d55571 | ||
|
|
7a7b9fde47 | ||
|
|
120567a6e5 | ||
|
|
9d1db4d035 | ||
|
|
eb07a99e5e | ||
|
|
85bd5209c7 |
@@ -22,4 +22,5 @@ jobs:
|
|||||||
working-directory: src/frontend
|
working-directory: src/frontend
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -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"]
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -3,8 +3,8 @@
|
|||||||
"name": "Dashboard Production",
|
"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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.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"
|
||||||
|
|||||||
16
docs/ai-infrastructure-workflow.mmd
Normal file
16
docs/ai-infrastructure-workflow.mmd
Normal 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
25
docs/ai-infrastructure.md
Normal 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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
74
src/backend/src/ai-catalog.js
Normal file
74
src/backend/src/ai-catalog.js
Normal 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 };
|
||||||
@@ -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
|
||||||
@@ -51,9 +112,15 @@ function normalizeManifest(manifest, directory, environment) {
|
|||||||
throw new Error('ci doit être un objet avec une propriété required booléenne');
|
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 {
|
return {
|
||||||
...manifest,
|
...manifest,
|
||||||
name: manifest.name.trim(),
|
name: displayName,
|
||||||
|
ai,
|
||||||
directory,
|
directory,
|
||||||
containerName: manifest.containerName || manifest.id,
|
containerName: manifest.containerName || manifest.id,
|
||||||
healthCheckUrl: manifest.urls[environment],
|
healthCheckUrl: manifest.urls[environment],
|
||||||
@@ -105,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}`);
|
||||||
}
|
}
|
||||||
@@ -120,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.
|
||||||
*/
|
*/
|
||||||
@@ -150,6 +236,7 @@ function findAppByRepo(config, repositoryName) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
discoverApps,
|
discoverApps,
|
||||||
refreshApps,
|
refreshApps,
|
||||||
|
refreshAppsIfStale,
|
||||||
writeAppManifest,
|
writeAppManifest,
|
||||||
findAppDirectoryByRepo,
|
findAppDirectoryByRepo,
|
||||||
findAppByRepo,
|
findAppByRepo,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
50
src/backend/test/active-ai-configuration.test.js
Normal file
50
src/backend/test/active-ai-configuration.test.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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,10 +24,31 @@ try {
|
|||||||
category: 'ITINOVA',
|
category: 'ITINOVA',
|
||||||
containerName: 'itinova-contacts',
|
containerName: 'itinova-contacts',
|
||||||
image: 'images/itinova-contacts.png',
|
image: 'images/itinova-contacts.png',
|
||||||
urls: {
|
ai: {
|
||||||
recette: 'https://contacts.recette.santinova-soft.org',
|
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }],
|
||||||
prod: 'https://contacts.santinova-soft.org',
|
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', {
|
writeManifest(sandbox, 'invalide', {
|
||||||
@@ -36,15 +57,48 @@ try {
|
|||||||
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, 1, '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[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.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');
|
||||||
|
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 = {
|
const config = {
|
||||||
appsBasePath: sandbox,
|
appsBasePath: sandbox,
|
||||||
@@ -52,8 +106,39 @@ 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, 1, 'les applications non déployées ne doivent pas rester en cache');
|
assert.equal(refresh.apps.length, 3, 'les applications non déployées ne doivent pas rester en cache');
|
||||||
assert.equal(config.apps[0].name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique');
|
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');
|
console.log('OK app-registry');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
50
src/backend/test/applications-ui-contract.test.js
Normal file
50
src/backend/test/applications-ui-contract.test.js
Normal 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');
|
||||||
14
src/backend/test/dockerfile-source-path.test.js
Normal file
14
src/backend/test/dockerfile-source-path.test.js
Normal 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');
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"test": "node --test test/*.test.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api';
|
import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api';
|
||||||
|
import { APP_DOMAIN_SUFFIX, ENVIRONMENT_LABEL_LOWER } from '../utils/environment';
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ id: 1, title: 'Informations', icon: FileText },
|
{ id: 1, title: 'Informations', icon: FileText },
|
||||||
@@ -180,7 +181,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
|
|||||||
Ajouter une application
|
Ajouter une application
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{errors.subdomain && (
|
{errors.subdomain && (
|
||||||
@@ -280,7 +281,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
|
|||||||
{formData.subdomain && !errors.subdomain && (
|
{formData.subdomain && !errors.subdomain && (
|
||||||
<p className="text-gray-500 text-xs mt-1 flex items-center gap-1">
|
<p className="text-gray-500 text-xs mt-1 flex items-center gap-1">
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
URL: https://{formData.subdomain}.recette.santinova-soft.org
|
URL: https://{formData.subdomain}.{APP_DOMAIN_SUFFIX}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.
|
L'application est en cours de déploiement. Suivez la progression dans l'onglet Déploiements.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 mt-2 text-xs">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -573,7 +574,7 @@ export default function AddAppModal({ isOpen, onClose, onSuccess }) {
|
|||||||
<div className="flex justify-between items-center px-4 py-3">
|
<div className="flex justify-between items-center px-4 py-3">
|
||||||
<span className="text-gray-400 text-sm">URL</span>
|
<span className="text-gray-400 text-sm">URL</span>
|
||||||
<span className="text-primary-400 text-sm">
|
<span className="text-primary-400 text-sm">
|
||||||
https://{formData.subdomain}.recette.santinova-soft.org
|
https://{formData.subdomain}.{APP_DOMAIN_SUFFIX}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center px-4 py-3">
|
<div className="flex justify-between items-center px-4 py-3">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
||||||
|
import { GITEA_HOST, GITEA_URL } from '../utils/environment';
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
@@ -137,12 +138,12 @@ export default function GiteaPage() {
|
|||||||
<p className="text-gray-400 mt-1">
|
<p className="text-gray-400 mt-1">
|
||||||
Explorez les dépôts sur{' '}
|
Explorez les dépôts sur{' '}
|
||||||
<a
|
<a
|
||||||
href="https://git.recette.santinova-soft.org"
|
href={GITEA_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary-400 hover:text-primary-300"
|
className="text-primary-400 hover:text-primary-300"
|
||||||
>
|
>
|
||||||
git.recette.santinova-soft.org
|
{GITEA_HOST}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,49 +7,49 @@ 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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,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 Production',
|
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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
38
src/frontend/src/utils/environment.js
Normal file
38
src/frontend/src/utils/environment.js
Normal 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;
|
||||||
28
src/frontend/test/documentation-ai.test.mjs
Normal file
28
src/frontend/test/documentation-ai.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
27
src/frontend/test/environment.test.mjs
Normal file
27
src/frontend/test/environment.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user