Compare commits
7 Commits
main
...
8092337ccd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8092337ccd | ||
|
|
2129c0e21d | ||
|
|
78e374b0db | ||
|
|
b1bcd59289 | ||
|
|
765ef94fbd | ||
|
|
a65806125a | ||
|
|
cbfeb6d163 |
@@ -1,26 +0,0 @@
|
||||
name: Validation Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Tests backend et build frontend
|
||||
runs-on: ci-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Installer et tester le backend
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
- name: Installer et construire le frontend
|
||||
working-directory: src/frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
npm run build
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,5 +3,3 @@ dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
.deployment-request
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Contrat `app.json`
|
||||
|
||||
Le dashboard et le portail applicatif utilisent exclusivement le manifeste placé dans le dossier de chaque application déployée :
|
||||
|
||||
```text
|
||||
/opt/manus-deploy/apps/<dossier-technique>/app.json
|
||||
```
|
||||
|
||||
> Une application sans manifeste valide n’est pas considérée comme déployée. Elle n’apparaît donc ni dans le dashboard ni dans le portail.
|
||||
|
||||
## Schéma minimal
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "itinova-contacts",
|
||||
"name": "Itinova Contacts",
|
||||
"category": "ITINOVA",
|
||||
"containerName": "itinova-contacts",
|
||||
"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-itinova.recette.santinova-soft.org",
|
||||
"prod": "https://contacts-itinova.santinova-soft.org"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Champ | Rôle | Règle |
|
||||
|---|---|---|
|
||||
| `id` | Identifiant stable de l’application | Minuscules, chiffres et tirets uniquement. |
|
||||
| `name` | Libellé affiché | Chaîne non vide. |
|
||||
| `category` | Emplacement dans le portail | `ITINOVA`, `SANTINOVA` ou `INFRA`. Les applications `INFRA` restent visibles dans le dashboard mais sont exclues du portail. |
|
||||
| `containerName` | Conteneur géré par Docker | Obligatoire pour le contrôle de démarrage et de statut. À défaut, le dashboard utilise `id`. |
|
||||
| `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. 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.
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,34 +1,43 @@
|
||||
# Stage 1: Build frontend from Git-tracked sources
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files and source from the versioned src tree.
|
||||
COPY src/frontend/package.json src/frontend/pnpm-lock.yaml* ./
|
||||
# Copy frontend package files
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
|
||||
RUN npm install
|
||||
COPY src/frontend/ ./
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production runtime
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Docker controls are required by the dashboard runtime.
|
||||
# Install docker CLI for docker compose commands
|
||||
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 dependencies and source from the versioned src tree.
|
||||
COPY src/backend/package.json src/backend/package-lock.json ./backend/
|
||||
# Copy backend
|
||||
COPY backend/package.json ./backend/
|
||||
WORKDIR /app/backend
|
||||
RUN npm ci --omit=dev
|
||||
COPY src/backend/ ./
|
||||
RUN npm install --production
|
||||
|
||||
# Copy the frontend built from the same Git revision.
|
||||
COPY backend/ ./
|
||||
|
||||
# Copy built frontend
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -28,5 +28,5 @@ docker compose build --no-cache && docker compose up -d
|
||||
|
||||
## Accès
|
||||
|
||||
- URL : https://dashboard-santinova.santinova-soft.org
|
||||
- URL : https://dashboard.santinova-soft.org
|
||||
- Login : adminItinova / Itinova69!
|
||||
|
||||
16
app.json
16
app.json
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "manus-dashboard",
|
||||
"name": "Dashboard Production",
|
||||
"category": "INFRA",
|
||||
"urls": {
|
||||
"recette": "https://dashboard-santinova.recette.santinova-soft.org",
|
||||
"prod": "https://dashboard-santinova.santinova-soft.org"
|
||||
},
|
||||
"containerName": "manus-dashboard",
|
||||
"image": "images/dashboard.png",
|
||||
"giteaRepo": "manus-dashboard",
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
@@ -38,23 +38,6 @@ module.exports = {
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'gestion-at-v2',
|
||||
name: 'Gestion des AT',
|
||||
description: 'Application de gestion des accidents du travail Itinova',
|
||||
directory: 'gestion-at-v2',
|
||||
giteaRepo: 'gestion-at-v2',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://at.recette.santinova-soft.org',
|
||||
prod: 'https://at.santinova-soft.org',
|
||||
},
|
||||
containerName: 'gestion-at',
|
||||
healthCheckUrl: 'https://at.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
@@ -155,8 +138,8 @@ module.exports = {
|
||||
containerName: 'demat-facturation-app',
|
||||
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
image: 'images/demat-facturation-dsi.jpg',
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
@@ -185,7 +168,7 @@ module.exports = {
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://formations.recette.santinova-soft.org',
|
||||
prod: 'https://formations.itinova.org',
|
||||
prod: 'https://formations.santinova-soft.org',
|
||||
},
|
||||
containerName: 'formation-manager-itinova',
|
||||
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
|
||||
@@ -201,11 +184,11 @@ module.exports = {
|
||||
giteaRepo: 'portail-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
prod: 'https://portail-santinova.santinova-soft.org',
|
||||
recette: 'https://portail.recette.santinova-soft.org',
|
||||
prod: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
containerName: 'portail-santinova',
|
||||
healthCheckUrl: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'INFRA',
|
||||
status: 'production',
|
||||
|
||||
@@ -33,6 +33,10 @@ app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// Route webhook montée en premier avec raw body pour la vérification HMAC
|
||||
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
|
||||
@@ -42,11 +46,6 @@ app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' })
|
||||
}
|
||||
next();
|
||||
}, webhookRoutes);
|
||||
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(morgan('combined'));
|
||||
// API routes
|
||||
app.use('/api', routes);
|
||||
|
||||
|
||||
@@ -19,14 +19,11 @@ const LOG_DIR = '/var/log/manus-deploy';
|
||||
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
|
||||
const REPO_TO_APP_MAP = {
|
||||
'itinova-contacts': 'itinova-contacts',
|
||||
'gestion-at-v2': 'gestion-at-v2',
|
||||
'itinova-podcasts': 'itinova-podcasts',
|
||||
'veille-reglementaire': 'veille-reglementaire',
|
||||
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
|
||||
'manus-dashboard': 'manus-dashboard',
|
||||
'itinova-budget-si': 'itinova-budget-si',
|
||||
'facturation-santinova': 'facturation-santinova',
|
||||
'demat-facturation-dsi': 'demat-facturation-dsi',
|
||||
};
|
||||
|
||||
// Déploiements en cours (évite les doubles déclenchements)
|
||||
|
||||
@@ -1,47 +1,42 @@
|
||||
services:
|
||||
dashboard:
|
||||
build:
|
||||
context: ./src
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: manus-dashboard
|
||||
# Redémarrage automatique après un arrêt anormal ; le processus reste borné.
|
||||
restart: always
|
||||
mem_limit: 512m
|
||||
privileged: true
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- DASHBOARD_ENV=prod
|
||||
- PORT=${PORT:-3001}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_USERNAME=${GITEA_USERNAME}
|
||||
- GITEA_PASSWORD=${GITEA_PASSWORD}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis}
|
||||
- GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
|
||||
- GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
|
||||
- GITEA_PASSWORD_PROD=${GITEA_PASSWORD}
|
||||
- APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps}
|
||||
- INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure}
|
||||
# Contrôles bornés et dédoublonnés par healthcheck.js.
|
||||
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- JWT_SECRET=manus-dashboard-jwt-secret-2026-recette
|
||||
- ADMIN_USERNAME=adminItinova
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- GITEA_RECETTE_URL=http://gitea:3000
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/manus-deploy:/opt/manus-deploy
|
||||
extra_hosts:
|
||||
- "git.santinova-soft.org:180.149.196.138"
|
||||
networks:
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard-santinova.santinova-soft.org`)"
|
||||
# Route HTTPS via dashboard.recette.santinova-soft.org
|
||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
|
||||
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
||||
- "traefik.http.routers.manus-dashboard.tls=true"
|
||||
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc"
|
||||
- "traefik.http.routers.manus-dashboard.priority=100"
|
||||
# Service
|
||||
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
|
||||
- "traefik.docker.network=web"
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard Recette - Gestion des Applications</title>
|
||||
<title>Dashboard - Gestion des Applications</title>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
||||
3012
frontend/package-lock.json
generated
3012
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
||||
<p className="text-xs text-gray-500">Recette</p>
|
||||
<p className="text-xs text-gray-500">{window.location.hostname.includes('recette') ? 'Recette' : 'Production'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,9 +53,9 @@ const RECETTE = {
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail-santinova.recette.santinova-soft.org',
|
||||
url: 'portail.recette.santinova-soft.org',
|
||||
desc: 'Vitrine centralisée des applications',
|
||||
href: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
href: 'https://portail.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
@@ -141,9 +141,9 @@ const PRODUCTION = {
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail-santinova.santinova-soft.org',
|
||||
url: 'portail.santinova-soft.org',
|
||||
desc: 'Vitrine applicative production',
|
||||
href: 'https://portail-santinova.santinova-soft.org',
|
||||
href: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
|
||||
@@ -84,12 +84,12 @@ const APPS_CONFIG = [
|
||||
id: 'portail-santinova',
|
||||
name: 'Portail Applicatif',
|
||||
repoName: 'portail-santinova',
|
||||
urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://portail-santinova.santinova-soft.org',
|
||||
urlRecette: 'https://portail.recette.santinova-soft.org',
|
||||
urlProd: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard Recette',
|
||||
name: 'Dashboard',
|
||||
repoName: 'manus-dashboard',
|
||||
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
||||
urlProd: 'https://dashboard.santinova-soft.org',
|
||||
@@ -107,9 +107,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
|
||||
if (!shaRec && !shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||
<Minus className="w-3 h-3" />
|
||||
N/A
|
||||
Non déployé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -123,9 +123,9 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
}
|
||||
if (!shaProd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Recette déployée, production vide">
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
Recette en avance
|
||||
Non déployé en prod
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -175,19 +175,19 @@ function SyncBadge({ versionRecette, versionProd }) {
|
||||
}
|
||||
|
||||
function VersionBadge({ version, loading }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||
<Tag className="w-3 h-3" />
|
||||
Chargement...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!version) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||
<Tag className="w-3 h-3" />
|
||||
Chargement...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
N/A
|
||||
Non déployé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function LoginPage({ onLogin }) {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
||||
<Server className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard Recette</h1>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dashboard {window.location.hostname.includes('recette') ? 'Recette' : 'Production'}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Image locale de CI : Node 22, Bash, Git, GNU tar et le pnpm exact du workspace Contacts.
|
||||
# Les jobs n'ont donc plus à télécharger ou installer ces outils à chaque exécution.
|
||||
FROM node:22-alpine
|
||||
|
||||
# actions/cache requiert GNU tar et l’invoque via /bin/tar.
|
||||
RUN apk add --no-cache bash git tar \
|
||||
&& tar --version | grep -q 'GNU tar' \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@10.4.1 --activate \
|
||||
&& pnpm --version
|
||||
@@ -1,17 +0,0 @@
|
||||
# Supervision des applications
|
||||
|
||||
Le script `healthcheck-apps.sh` est exécuté par le timer systemd toutes les cinq minutes et trois minutes après chaque démarrage serveur. Il vérifie les conteneurs Docker Compose, leur healthcheck, leur politique de redémarrage et les routes HTTPS Traefik déclarées. Une tâche ponctuelle terminée avec succès est reconnue comme telle. Toute réponse HTTP inférieure à 500 confirme la joignabilité d’une route, y compris une API sans page racine ou un tableau protégé. En cas d’anomalie, il produit un rapport et une entrée de journal, **sans redémarrer de conteneur**. Les redémarrages automatiques restent assurés par les politiques Docker `restart` et par `manus-apps.service` au démarrage du serveur.
|
||||
|
||||
Le dernier rapport est écrit dans `/var/lib/manus-apps-health/latest.txt`. Les anomalies sont aussi consignées dans le journal système avec le tag `manus-apps-health`.
|
||||
|
||||
Le runner de recette est installé par `install-gitea-act-runner.sh`. Il construit une image locale Node 22 avec Bash, Git, GNU tar et pnpm 10.4.1 préactivé, puis s’enregistre auprès de Gitea avec un jeton éphémère. Les workflows s’exécutent ensuite dans cette image isolée sur le réseau Docker `web`. GNU tar est requis par `actions/cache` pour créer et restaurer les archives de dépendances.
|
||||
|
||||
Le cache Gitea Actions est actif sur le port hôte `18088` et stocke ses archives sous `/opt/manus-deploy/gitea-runner/cache`. Les workflows doivent utiliser une clé dérivée de leur fichier de verrouillage, par exemple `pnpm-lock.yaml`, afin de ne jamais réutiliser des dépendances incompatibles.
|
||||
|
||||
Les pushes du dépôt `manus-dashboard` créent une demande atomique prise en charge par `manus-dashboard-self-deploy.path`. Le conteneur ne se redéploie donc jamais lui-même : le service systemd hôte effectue le build et le démarrage du remplacement.
|
||||
|
||||
Après mise à jour Git, installer ou actualiser le service avec :
|
||||
|
||||
```bash
|
||||
sudo ./ops/install-healthcheck.sh
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=Surveille la file des alertes CI Santinova
|
||||
|
||||
[Path]
|
||||
PathChanged=/opt/manus-deploy/secrets/ci-alerts/outbox
|
||||
Unit=ci-alert-email-dispatch.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expédie les alertes CI mises en file par le dashboard depuis l’hôte de recette."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OUTBOX = Path("/opt/manus-deploy/secrets/ci-alerts/outbox")
|
||||
ARCHIVE = Path("/var/log/manus-deploy/ci-alerts-email-sent.json")
|
||||
|
||||
|
||||
def send_alert(file_path: Path) -> None:
|
||||
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
recipient = str(payload.get("to", "")).strip()
|
||||
subject = str(payload.get("subject", "")).strip()
|
||||
text = str(payload.get("text", "")).strip()
|
||||
if not recipient or "@" not in recipient or not subject or not text:
|
||||
raise ValueError(f"Alerte CI invalide : {file_path.name}")
|
||||
message = EmailMessage()
|
||||
message["To"] = recipient
|
||||
message["From"] = "alertes-ci@itinova.org"
|
||||
message["Subject"] = subject
|
||||
message.set_content(text)
|
||||
subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=message.as_bytes(), check=True, timeout=30)
|
||||
ARCHIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ARCHIVE.write_text(json.dumps({"id": payload.get("id"), "sentAt": payload.get("createdAt")}) + "\n", encoding="utf-8")
|
||||
file_path.unlink()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
failures = []
|
||||
for file_path in sorted(OUTBOX.glob("*.json")):
|
||||
try:
|
||||
send_alert(file_path)
|
||||
except Exception as error: # Le fichier reste en file pour réessai et investigation.
|
||||
print(f"CI_EMAIL_DISPATCH_FAILED {file_path.name}: {error}", file=sys.stderr)
|
||||
failures.append(file_path.name)
|
||||
if failures:
|
||||
return 1
|
||||
print("CI_EMAIL_DISPATCH_OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Envoi hôte des alertes CI Santinova vers le DSI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/ci-alert-email-dispatch.py
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Le dashboard est redéployé par l’hôte, jamais par le conteneur qu’il remplace.
|
||||
set -Eeuo pipefail
|
||||
readonly APP_DIR="/opt/manus-deploy/apps/manus-dashboard"
|
||||
readonly REQUEST_FILE="${APP_DIR}/.deployment-request"
|
||||
readonly PROCESSING_FILE="${APP_DIR}/.deployment-request.processing"
|
||||
readonly LOCK_FILE="/run/manus-dashboard-self-deploy.lock"
|
||||
|
||||
exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || exit 0
|
||||
[[ -f "${REQUEST_FILE}" ]] || exit 0
|
||||
mv "${REQUEST_FILE}" "${PROCESSING_FILE}"
|
||||
restore_request() { [[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"; }
|
||||
trap restore_request ERR
|
||||
cd "${APP_DIR}"
|
||||
git pull --ff-only origin main
|
||||
docker compose up -d --build
|
||||
rm -f "${PROCESSING_FILE}"
|
||||
logger -p daemon.info -t manus-dashboard-self-deploy "Dashboard redéployé depuis le service hôte"
|
||||
@@ -1,18 +0,0 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
capacity: 1
|
||||
envs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
# Le cache intégré est servi par le runner hôte et accessible par les jobs
|
||||
# depuis le réseau Docker web. Les clés restent définies par chaque workflow.
|
||||
cache:
|
||||
enabled: true
|
||||
dir: /opt/manus-deploy/gitea-runner/cache
|
||||
host: 172.18.0.1
|
||||
port: 18088
|
||||
|
||||
container:
|
||||
network: web
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=Runner Gitea Actions de recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/manus-deploy/gitea-runner
|
||||
Environment=DOCKER_HOST=unix:///var/run/docker.sock
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /opt/manus-deploy/gitea-runner/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=Surveillance de la validation manuelle du jeton Gitea Santinova
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request
|
||||
Unit=gitea-token-rotation-apply.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Application manuelle validée du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
@@ -1,12 +0,0 @@
|
||||
[Unit]
|
||||
Description=Préparation contrôlée de la rotation du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Nice=10
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly COMMAND="${1:-status}"
|
||||
readonly FORCE="${2:-}"
|
||||
readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}"
|
||||
readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}"
|
||||
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 STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}"
|
||||
readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}"
|
||||
readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token"
|
||||
readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name"
|
||||
readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name"
|
||||
readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation"
|
||||
readonly LOCK_FILE="${STATE_DIR}/rotation.lock"
|
||||
readonly APPROVAL_FILE="${STATE_DIR}/approval.request"
|
||||
readonly TOKEN_PREFIX="santinova-recette"
|
||||
readonly MAX_AGE_DAYS="90"
|
||||
|
||||
install -d -m 0700 "${STATE_DIR}"
|
||||
install -d -m 0750 "$(dirname "${STATUS_FILE}")"
|
||||
exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; }
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g'
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1"
|
||||
local message="$2"
|
||||
local token_name="${3:-}"
|
||||
local temporary
|
||||
temporary="$(mktemp "${STATUS_FILE}.XXXXXX")"
|
||||
chmod 0640 "${temporary}"
|
||||
printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \
|
||||
"$(json_escape "${state}")" \
|
||||
"$(json_escape "${message}")" \
|
||||
"$(json_escape "${token_name}")" \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}"
|
||||
mv "${temporary}" "${STATUS_FILE}"
|
||||
}
|
||||
|
||||
read_env_token() {
|
||||
sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1
|
||||
}
|
||||
|
||||
validate_token() {
|
||||
local token="$1"
|
||||
local response status result=1
|
||||
response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
|
||||
if status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")" \
|
||||
&& [[ "${status}" == "200" ]] \
|
||||
&& grep -q '"login":"manus-admin"' "${response}" \
|
||||
&& status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")" \
|
||||
&& [[ "${status}" == "200" ]] \
|
||||
&& status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")" \
|
||||
&& [[ "${status}" == "200" ]]; then
|
||||
result=0
|
||||
fi
|
||||
rm -f "${response}"
|
||||
return "${result}"
|
||||
}
|
||||
|
||||
rotation_due() {
|
||||
[[ "${FORCE}" == "--force" ]] && return 0
|
||||
[[ -f "${LAST_ROTATION_FILE}" ]] || return 0
|
||||
local last_epoch now_epoch max_age_seconds
|
||||
last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)"
|
||||
now_epoch="$(date +%s)"
|
||||
max_age_seconds="$((MAX_AGE_DAYS * 86400))"
|
||||
(( now_epoch - last_epoch >= max_age_seconds ))
|
||||
}
|
||||
|
||||
prepare_rotation() {
|
||||
if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then
|
||||
local existing_pending
|
||||
existing_pending="$(<"${PENDING_TOKEN_FILE}")"
|
||||
validate_token "${existing_pending}"
|
||||
write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")"
|
||||
echo "GITEA_ROTATION_PENDING"
|
||||
return 0
|
||||
fi
|
||||
if ! rotation_due; then
|
||||
write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)"
|
||||
echo "GITEA_ROTATION_NOT_DUE"
|
||||
return 0
|
||||
fi
|
||||
local current_token token_name pending_token
|
||||
current_token="$(read_env_token)"
|
||||
[[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; }
|
||||
token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)"
|
||||
pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \
|
||||
--username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)"
|
||||
[[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; }
|
||||
printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}"
|
||||
printf '%s' "${token_name}" > "${PENDING_NAME_FILE}"
|
||||
chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
if ! validate_token "${pending_token}"; then
|
||||
rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}"
|
||||
exit 1
|
||||
fi
|
||||
write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}"
|
||||
echo "GITEA_ROTATION_PREPARED"
|
||||
}
|
||||
|
||||
apply_rotation() {
|
||||
trap 'rm -f "${APPROVAL_FILE}"' EXIT
|
||||
[[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; }
|
||||
local pending_token pending_name current_token backup
|
||||
pending_token="$(<"${PENDING_TOKEN_FILE}")"
|
||||
pending_name="$(<"${PENDING_NAME_FILE}")"
|
||||
current_token="$(read_env_token)"
|
||||
validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; }
|
||||
backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")"
|
||||
chmod 0600 "${backup}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}"
|
||||
if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then
|
||||
printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}"
|
||||
fi
|
||||
write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}"
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
rm -f "${backup}"
|
||||
write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}"
|
||||
chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}"
|
||||
write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}"
|
||||
echo "GITEA_ROTATION_APPLIED"
|
||||
}
|
||||
|
||||
case "${COMMAND}" in
|
||||
prepare) prepare_rotation ;;
|
||||
apply) apply_rotation ;;
|
||||
status)
|
||||
if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi
|
||||
;;
|
||||
*) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Préparation trimestrielle du jeton Gitea Santinova
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-01,04,07,10-01 07:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30m
|
||||
Unit=gitea-token-rotation.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Vérifie les conteneurs Compose, leur healthcheck, leur redémarrage et leurs routes Traefik.
|
||||
# Ce contrôle est volontairement non intrusif : Docker et manus-apps.service assurent les redémarrages.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly STATE_DIR="/var/lib/manus-apps-health"
|
||||
readonly REPORT_FILE="${STATE_DIR}/latest.txt"
|
||||
readonly LOCK_FILE="/run/manus-apps-health.lock"
|
||||
readonly MODE="${1:---check-only}"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || exit 0
|
||||
|
||||
declare -A SEEN_HTTP_HOSTS=()
|
||||
declare -a REPORT_LINES=()
|
||||
HAS_FAILURE=false
|
||||
|
||||
log_line() {
|
||||
local level="$1"
|
||||
local message="$2"
|
||||
local line="[$(date -Is)] [$level] $message"
|
||||
REPORT_LINES+=("$line")
|
||||
echo "$line"
|
||||
}
|
||||
|
||||
mark_failure() {
|
||||
HAS_FAILURE=true
|
||||
log_line "KO" "$1"
|
||||
}
|
||||
|
||||
is_healthy_http_status() {
|
||||
# Toute réponse HTTP (< 500) confirme que Traefik et le service restent joignables.
|
||||
# Les 401/404 sont légitimes pour les tableaux protégés ou API sans page racine.
|
||||
[[ "$1" =~ ^[1-4][0-9][0-9]$ ]]
|
||||
}
|
||||
|
||||
check_http_route() {
|
||||
local container_id="$1"
|
||||
local project="$2"
|
||||
local labels host_rule host status
|
||||
|
||||
labels="$(docker inspect -f '{{range $key, $value := .Config.Labels}}{{$key}}={{$value}}{{"\n"}}{{end}}' "$container_id")"
|
||||
host_rule="$(printf '%s\n' "$labels" | grep -oE 'Host\(`[^`]+`\)' | head -n 1 || true)"
|
||||
[[ -n "$host_rule" ]] || return 0
|
||||
|
||||
host="${host_rule#Host(\`}"
|
||||
host="${host%\`)}"
|
||||
[[ -n "$host" ]] || return 0
|
||||
[[ -z "${SEEN_HTTP_HOSTS[$host]:-}" ]] || return 0
|
||||
SEEN_HTTP_HOSTS["$host"]=1
|
||||
|
||||
status="$(curl --silent --show-error --location --max-redirs 3 --connect-timeout 5 --max-time 8 \
|
||||
--output /dev/null --write-out '%{http_code}' "https://${host}" 2>/dev/null || true)"
|
||||
if is_healthy_http_status "$status"; then
|
||||
log_line "OK" "${project}: HTTPS ${host} → ${status}"
|
||||
else
|
||||
# Un échec HTTPS peut dépendre de Traefik, DNS ou d’une redirection applicative.
|
||||
# Il déclenche une alerte, jamais un redémarrage Compose automatique.
|
||||
mark_failure "${project}: HTTPS ${host} → ${status:-erreur réseau}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_containers() {
|
||||
local container_id name project workdir config_files status health restart_policy exit_code
|
||||
mapfile -t container_ids < <(docker ps -aq --filter label=com.docker.compose.project)
|
||||
|
||||
if [[ "${#container_ids[@]}" -eq 0 ]]; then
|
||||
mark_failure "Aucun conteneur Docker Compose détecté"
|
||||
return
|
||||
fi
|
||||
|
||||
for container_id in "${container_ids[@]}"; do
|
||||
name="$(docker inspect -f '{{.Name}}' "$container_id" | sed 's#^/##')"
|
||||
project="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")"
|
||||
workdir="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$container_id")"
|
||||
config_files="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$container_id")"
|
||||
status="$(docker inspect -f '{{.State.Status}}' "$container_id")"
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")"
|
||||
restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$container_id")"
|
||||
exit_code="$(docker inspect -f '{{.State.ExitCode}}' "$container_id")"
|
||||
|
||||
if [[ "$status" != "running" ]]; then
|
||||
if [[ "$status" == "exited" && "$exit_code" == "0" ]]; then
|
||||
log_line "INFO" "${project}/${name}: tâche ponctuelle terminée"
|
||||
continue
|
||||
fi
|
||||
mark_failure "${project}/${name}: état Docker ${status}"
|
||||
continue
|
||||
fi
|
||||
if [[ "$health" == "unhealthy" ]]; then
|
||||
mark_failure "${project}/${name}: healthcheck unhealthy"
|
||||
continue
|
||||
fi
|
||||
if [[ "$restart_policy" != "unless-stopped" && "$restart_policy" != "always" ]]; then
|
||||
mark_failure "${project}/${name}: politique de redémarrage ${restart_policy:-none}"
|
||||
continue
|
||||
fi
|
||||
|
||||
log_line "OK" "${project}/${name}: running, health=${health}, restart=${restart_policy}"
|
||||
check_http_route "$container_id" "$project"
|
||||
done
|
||||
}
|
||||
|
||||
check_containers
|
||||
|
||||
{
|
||||
echo "Supervision Santinova / Itinova"
|
||||
echo "Généré : $(date -Is)"
|
||||
echo "Statut : $([[ "$HAS_FAILURE" == true ]] && echo KO || echo OK)"
|
||||
printf '%s\n' "${REPORT_LINES[@]}"
|
||||
} > "${REPORT_FILE}.tmp"
|
||||
mv "${REPORT_FILE}.tmp" "$REPORT_FILE"
|
||||
|
||||
if [[ "$HAS_FAILURE" == true ]]; then
|
||||
logger -p daemon.err -t manus-apps-health "Anomalie détectée : consulter ${REPORT_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
logger -p daemon.info -t manus-apps-health "Toutes les applications contrôlées sont opérationnelles"
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly SCRIPT_DIR="/opt/manus-deploy/scripts"
|
||||
readonly OUTBOX_DIR="/opt/manus-deploy/secrets/ci-alerts/outbox"
|
||||
|
||||
install -d -m 0700 "${OUTBOX_DIR}"
|
||||
install -m 0700 "${APP_DIR}/ops/ci-alert-email-dispatch.py" "${SCRIPT_DIR}/ci-alert-email-dispatch.py"
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.service" /etc/systemd/system/ci-alert-email-dispatch.service
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.path" /etc/systemd/system/ci-alert-email-dispatch.path
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ci-alert-email-dispatch.path
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe et enregistre le runner de recette depuis les fichiers versionnés du dashboard.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
|
||||
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
|
||||
readonly GITEA_CONTAINER="gitea"
|
||||
readonly GITEA_URL="https://git.recette.santinova-soft.org"
|
||||
readonly RUNNER_NAME="recette-docker-runner"
|
||||
readonly RUNNER_IMAGE="gitea-runner-node:22"
|
||||
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
|
||||
readonly LABEL_FILE="${RUNNER_DIR}/labels"
|
||||
|
||||
install -d -m 0750 "${RUNNER_DIR}"
|
||||
install -d -m 0750 "${RUNNER_DIR}/cache"
|
||||
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
|
||||
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
|
||||
|
||||
# Migration unique : les versions antérieures enregistraient le runner dans APP_DIR.
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" && -f "${APP_DIR}/.runner" ]]; then
|
||||
mv "${APP_DIR}/.runner" "${RUNNER_DIR}/.runner"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
|
||||
systemctl stop gitea-act-runner.service 2>/dev/null || true
|
||||
rm -f "${RUNNER_DIR}/.runner"
|
||||
token="$(docker exec -u git "${GITEA_CONTAINER}" gitea actions generate-runner-token)"
|
||||
(
|
||||
cd "${RUNNER_DIR}"
|
||||
/usr/local/bin/act_runner register --no-interactive \
|
||||
--instance "${GITEA_URL}" \
|
||||
--token "${token}" \
|
||||
--name "${RUNNER_NAME}" \
|
||||
--labels "${RUNNER_LABELS}" \
|
||||
--config "${RUNNER_DIR}/config.yaml"
|
||||
)
|
||||
printf '%s\n' "${RUNNER_LABELS}" > "${LABEL_FILE}"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-act-runner.service
|
||||
systemctl is-active gitea-act-runner.service
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path
|
||||
install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation
|
||||
install -d -m 0750 /var/log/manus-deploy
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-token-rotation.timer
|
||||
systemctl enable --now gitea-token-rotation-apply.path
|
||||
systemctl is-enabled gitea-token-rotation.timer
|
||||
systemctl is-enabled gitea-token-rotation-apply.path
|
||||
systemctl list-timers gitea-token-rotation.timer --no-pager
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe la supervision depuis une copie versionnée du dépôt manus-dashboard.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
install -D -m 0750 "${SCRIPT_DIR}/healthcheck-apps.sh" /opt/manus-deploy/scripts/healthcheck-apps.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.service" /etc/systemd/system/manus-apps-health.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.timer" /etc/systemd/system/manus-apps-health.timer
|
||||
install -D -m 0750 "${SCRIPT_DIR}/deploy-dashboard-from-host.sh" /opt/manus-deploy/scripts/deploy-dashboard-from-host.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.service" /etc/systemd/system/manus-dashboard-self-deploy.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.path" /etc/systemd/system/manus-dashboard-self-deploy.path
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now manus-apps-health.timer
|
||||
systemctl enable --now manus-dashboard-self-deploy.path
|
||||
# Un échec de contrôle doit rester visible dans systemd, sans bloquer l’installation du timer.
|
||||
systemctl start manus-apps-health.service || true
|
||||
systemctl status manus-apps-health.service --no-pager || true
|
||||
@@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=Contrôle de santé des applications Santinova / Itinova
|
||||
Documentation=https://git.santinova-soft.org/manus-admin/manus-dashboard
|
||||
After=docker.service network-online.target manus-apps.service
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/healthcheck-apps.sh --check-only
|
||||
TimeoutStartSec=5min
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -1,12 +0,0 @@
|
||||
[Unit]
|
||||
Description=Planification de la supervision des applications Santinova / Itinova
|
||||
|
||||
[Timer]
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=5min
|
||||
RandomizedDelaySec=30s
|
||||
Persistent=true
|
||||
Unit=manus-apps-health.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=Surveille les demandes de redéploiement du Manus Dashboard recette
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/apps/manus-dashboard/.deployment-request
|
||||
Unit=manus-dashboard-self-deploy.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=Redéploiement hôte du Manus Dashboard recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/deploy-dashboard-from-host.sh
|
||||
Restart=on-failure
|
||||
RestartSec=1min
|
||||
TimeoutStartSec=10min
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
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-santinova.recette.santinova-soft.org}"
|
||||
|
||||
backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
|
||||
temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"
|
||||
chmod 0600 "${backup}" "${temporary}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
|
||||
cleanup() {
|
||||
rm -f "${backup}" "${temporary}"
|
||||
unset token result
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')"
|
||||
token="${result%%$'\t'*}"
|
||||
token_id="${result##*$'\t'}"
|
||||
[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; }
|
||||
|
||||
awk -v value="${token}" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next }
|
||||
{ print }
|
||||
END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value }
|
||||
' "${DASHBOARD_ENV_FILE}" > "${temporary}"
|
||||
mv "${temporary}" "${DASHBOARD_ENV_FILE}"
|
||||
chmod 0600 "${DASHBOARD_ENV_FILE}"
|
||||
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 8
|
||||
health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")"
|
||||
if [[ "${health}" != "204" ]]; then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null
|
||||
echo "DASHBOARD_ALERT_TOKEN_PROVISIONED"
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
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-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"
|
||||
|
||||
|
||||
def read_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def update_env(key: str, value: str) -> None:
|
||||
lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else []
|
||||
updated: list[str] = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if line.startswith(f"{key}="):
|
||||
updated.append(f"{key}={value}")
|
||||
found = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not found:
|
||||
updated.append(f"{key}={value}")
|
||||
temporary = ENV_FILE.with_suffix(".env.next")
|
||||
temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(ENV_FILE)
|
||||
|
||||
|
||||
def api(token: str, method: str, path: str, payload: dict | None = None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{GITEA_URL}/api/v1{path}",
|
||||
method=method,
|
||||
data=data,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode(errors="replace")
|
||||
raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error
|
||||
|
||||
|
||||
def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]:
|
||||
if values.get("WEBHOOK_SECRET"):
|
||||
return values, False
|
||||
update_env("WEBHOOK_SECRET", secrets.token_hex(32))
|
||||
return read_env(), True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
values, generated = ensure_dashboard_secret(read_env())
|
||||
token = values.get("GITEA_TOKEN", "")
|
||||
secret = values.get("WEBHOOK_SECRET", "")
|
||||
if not token or not secret:
|
||||
raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.")
|
||||
if generated:
|
||||
subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True)
|
||||
time.sleep(8)
|
||||
identity = api(token, "GET", "/user")
|
||||
if identity.get("login") != GITEA_USER:
|
||||
raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.")
|
||||
repositories = api(token, "GET", "/user/repos?limit=100&page=1") or []
|
||||
configured = 0
|
||||
for repository in repositories:
|
||||
owner = repository.get("owner", {}).get("login")
|
||||
name = repository.get("name")
|
||||
if owner != GITEA_USER or not name:
|
||||
continue
|
||||
hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or []
|
||||
existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None)
|
||||
payload = {
|
||||
"active": True,
|
||||
"branch_filter": "main",
|
||||
"config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret},
|
||||
"events": ["push", "workflow_run"],
|
||||
}
|
||||
if existing:
|
||||
api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload)
|
||||
else:
|
||||
api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload})
|
||||
configured += 1
|
||||
print(f"WEBHOOK_OK {owner}/{name}")
|
||||
if configured == 0:
|
||||
raise RuntimeError("Aucun dépôt Gitea n’a été configuré.")
|
||||
print(f"GITEA_WEBHOOKS_CONFIGURED {configured}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -25,10 +25,10 @@ RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh
|
||||
RUN git config --global user.email "admin@santinova-soft.org" && \
|
||||
git config --global user.name "Manus Dashboard"
|
||||
|
||||
# Installer le backend depuis un verrou versionné pour un build déterministe.
|
||||
COPY backend/package.json backend/package-lock.json ./backend/
|
||||
# Copy backend
|
||||
COPY backend/package.json ./backend/
|
||||
WORKDIR /app/backend
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm install --production
|
||||
|
||||
COPY backend/ ./
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@ docker compose build --no-cache && docker compose up -d
|
||||
|
||||
## Accès
|
||||
|
||||
- URL : https://dashboard-santinova.santinova-soft.org
|
||||
- URL : https://dashboard.santinova-soft.org
|
||||
- Login : adminItinova / Itinova69!
|
||||
|
||||
2483
src/backend/package-lock.json
generated
2483
src/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,7 @@
|
||||
"main": "src/index.js",
|
||||
"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/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"
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,39 +1,9 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const config = require('./config');
|
||||
const { createRepo, giteaClient } = require('./gitea');
|
||||
const { refreshApps, writeAppManifest } = require('./app-registry');
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
async function createGiteaGitAuth() {
|
||||
if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.');
|
||||
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-'));
|
||||
const tokenPath = path.join(directory, 'token');
|
||||
const askpassPath = path.join(directory, 'askpass.sh');
|
||||
await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 });
|
||||
await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 });
|
||||
return {
|
||||
env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' },
|
||||
cleanup: () => fsp.rm(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGiteaRepoUrl(value) {
|
||||
const url = new URL(value);
|
||||
const configured = new URL(config.gitea.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== configured.hostname) {
|
||||
throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ============ TEMPLATES DOCKERFILE ============
|
||||
|
||||
@@ -419,29 +389,92 @@ function getContainerPort(stack, customPort) {
|
||||
return defaults[stack] || 3000;
|
||||
}
|
||||
|
||||
// ============ REGISTRE DES APPLICATIONS ============
|
||||
// ============ APPS PERSISTENCE ============
|
||||
|
||||
/**
|
||||
* Recharge le cache mémoire à partir des seuls manifests app.json.
|
||||
* Les anciens fichiers de liste applicative ne sont plus lus : ils pouvaient
|
||||
* afficher des applications non déployées et désynchroniser le portail.
|
||||
*/
|
||||
function initDynamicApps() {
|
||||
return refreshApps(config);
|
||||
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
|
||||
|
||||
function loadDynamicApps() {
|
||||
try {
|
||||
if (fs.existsSync(APPS_FILE)) {
|
||||
const data = fs.readFileSync(APPS_FILE, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement apps dynamiques:', err.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveDynamicApps(apps) {
|
||||
try {
|
||||
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
|
||||
} catch (err) {
|
||||
console.error('Erreur sauvegarde apps dynamiques:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie le manifeste d'une application créée depuis le dashboard, puis
|
||||
* rafraîchit immédiatement le registre partagé par le dashboard et le portail.
|
||||
*/
|
||||
function addDynamicApp(appDef) {
|
||||
const app = writeAppManifest({
|
||||
appsBasePath: config.appsBasePath,
|
||||
environment: config.environment,
|
||||
app: appDef,
|
||||
});
|
||||
refreshApps(config);
|
||||
return app;
|
||||
const apps = loadDynamicApps();
|
||||
// Éviter les doublons
|
||||
const existing = apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existing >= 0) {
|
||||
apps[existing] = appDef;
|
||||
} else {
|
||||
apps.push(appDef);
|
||||
}
|
||||
saveDynamicApps(apps);
|
||||
|
||||
// Ajouter aussi à config.apps en mémoire
|
||||
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existingInConfig >= 0) {
|
||||
config.apps[existingInConfig] = appDef;
|
||||
} else {
|
||||
config.apps.push(appDef);
|
||||
}
|
||||
}
|
||||
|
||||
function initDynamicApps() {
|
||||
// 1. Charger les apps depuis .dashboard-apps.json (créées via l'interface)
|
||||
const dynamicApps = loadDynamicApps();
|
||||
for (const app of dynamicApps) {
|
||||
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
|
||||
if (existingInConfig < 0) {
|
||||
config.apps.push(app);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Découverte automatique : scanner les dossiers apps/*.json
|
||||
let discovered = 0;
|
||||
try {
|
||||
const appsBasePath = config.appsBasePath;
|
||||
if (fs.existsSync(appsBasePath)) {
|
||||
const entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const appJsonPath = path.join(appsBasePath, entry.name, 'app.json');
|
||||
if (!fs.existsSync(appJsonPath)) continue;
|
||||
try {
|
||||
const appDef = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
|
||||
if (!appDef.id) continue;
|
||||
// Ne pas écraser une app déjà en config (statique ou dynamique)
|
||||
const existingIdx = config.apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existingIdx >= 0) {
|
||||
// Mettre à jour avec les données du app.json (source de vérité)
|
||||
config.apps[existingIdx] = { ...config.apps[existingIdx], ...appDef };
|
||||
} else {
|
||||
config.apps.push(appDef);
|
||||
discovered++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[discovery] Erreur lecture ${appJsonPath}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[discovery] Erreur scan apps:', err.message);
|
||||
}
|
||||
|
||||
console.log(`Apps chargées: ${dynamicApps.length} dynamiques + ${discovered} découvertes (total: ${config.apps.length})`);
|
||||
}
|
||||
|
||||
// ============ CREATION D'APPLICATION ============
|
||||
@@ -455,7 +488,6 @@ async function createApplication(params, logCallback) {
|
||||
port,
|
||||
needsDb,
|
||||
dbType,
|
||||
category,
|
||||
sourceType, // 'zip' or 'gitea'
|
||||
giteaRepoUrl, // URL du repo Gitea existant
|
||||
zipFilePath, // Chemin du fichier ZIP uploadé
|
||||
@@ -465,7 +497,6 @@ async function createApplication(params, logCallback) {
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const appDir = path.join(config.appsBasePath, appId);
|
||||
const srcDir = path.join(appDir, 'src');
|
||||
let gitAuth = null;
|
||||
|
||||
const log = (msg) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
@@ -474,7 +505,6 @@ async function createApplication(params, logCallback) {
|
||||
};
|
||||
|
||||
try {
|
||||
gitAuth = await createGiteaGitAuth();
|
||||
// 1. Créer le répertoire de l'application
|
||||
log(`Création du répertoire ${appDir}...`);
|
||||
await fsp.mkdir(appDir, { recursive: true });
|
||||
@@ -486,8 +516,11 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'gitea') {
|
||||
// 2a. Cloner depuis un dépôt Gitea existant
|
||||
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
||||
const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
|
||||
await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
|
||||
const cloneUrl = giteaRepoUrl.replace(
|
||||
'https://',
|
||||
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
|
||||
);
|
||||
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
|
||||
log('Clonage terminé.');
|
||||
|
||||
// Extraire le nom du repo depuis l'URL
|
||||
@@ -531,11 +564,10 @@ async function createApplication(params, logCallback) {
|
||||
|
||||
// Initialiser git et pousser le code
|
||||
log('Initialisation Git et push vers Gitea...');
|
||||
const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${config.gitea.username}/${appId}.git`;
|
||||
const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
|
||||
await execCommand(
|
||||
`cd ${shellQuote(srcDir)} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`,
|
||||
120000,
|
||||
gitAuth.env
|
||||
`cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
|
||||
120000
|
||||
);
|
||||
log('Code poussé sur Gitea.');
|
||||
}
|
||||
@@ -577,8 +609,7 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'zip') {
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
|
||||
60000,
|
||||
gitAuth.env
|
||||
60000
|
||||
);
|
||||
log('Dockerfile commité et poussé sur Gitea.');
|
||||
}
|
||||
@@ -654,8 +685,6 @@ async function createApplication(params, logCallback) {
|
||||
stack: stack,
|
||||
needsDb: needsDb || false,
|
||||
dbType: dbType || null,
|
||||
category: category || 'SANTINOVA',
|
||||
image: `images/${appId}.png`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -672,14 +701,12 @@ async function createApplication(params, logCallback) {
|
||||
} catch (err) {
|
||||
log(`ERREUR: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await gitAuth?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(cmd, timeout = 60000, env = process.env) {
|
||||
function execCommand(cmd, timeout = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||
} else {
|
||||
@@ -710,6 +737,7 @@ module.exports = {
|
||||
generateDockerCompose,
|
||||
getAvailableStacks,
|
||||
initDynamicApps,
|
||||
loadDynamicApps,
|
||||
addDynamicApp,
|
||||
DOCKERFILE_TEMPLATES,
|
||||
};
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Registre des applications déployées.
|
||||
*
|
||||
* Source de vérité : /opt/manus-deploy/apps/<dossier>/app.json.
|
||||
* Le dashboard et le portail ne doivent jamais dépendre d'une liste applicative
|
||||
* codée en dur : une application sans manifeste n'est pas considérée déployée.
|
||||
*/
|
||||
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;
|
||||
try {
|
||||
return new URL(value).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* manifeste : ils sont calculés depuis le dossier et l'environnement courant.
|
||||
*/
|
||||
function normalizeManifest(manifest, directory, environment) {
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new Error('le manifeste doit être un objet JSON');
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(manifest.id || '')) {
|
||||
throw new Error('id absent ou invalide');
|
||||
}
|
||||
if (typeof manifest.name !== 'string' || manifest.name.trim().length === 0) {
|
||||
throw new Error('name absent ou invalide');
|
||||
}
|
||||
if (!CATEGORIES.has(manifest.category)) {
|
||||
throw new Error('category doit être ITINOVA, SANTINOVA ou INFRA');
|
||||
}
|
||||
if (!manifest.urls || typeof manifest.urls !== 'object') {
|
||||
throw new Error('urls absent ou invalide');
|
||||
}
|
||||
if (!isHttpsUrl(manifest.urls[environment])) {
|
||||
throw new Error(`urls.${environment} absent ou invalide`);
|
||||
}
|
||||
if (manifest.ci !== undefined && (
|
||||
!manifest.ci || typeof manifest.ci !== 'object' || Array.isArray(manifest.ci) ||
|
||||
(manifest.ci.required !== undefined && typeof manifest.ci.required !== 'boolean')
|
||||
)) {
|
||||
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: displayName,
|
||||
ai,
|
||||
directory,
|
||||
containerName: manifest.containerName || manifest.id,
|
||||
healthCheckUrl: manifest.urls[environment],
|
||||
ci: { required: manifest.ci?.required === true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Découvre les applications depuis le système de fichiers sans modifier la
|
||||
* configuration mémoire. Les erreurs de manifeste sont retournées explicitement
|
||||
* afin qu'un manifeste défaillant ne fasse pas tomber le dashboard.
|
||||
*/
|
||||
function discoverApps({ appsBasePath, environment }) {
|
||||
if (!ENVIRONMENTS.has(environment)) {
|
||||
throw new Error(`Environnement non supporté : ${environment}`);
|
||||
}
|
||||
|
||||
const apps = [];
|
||||
const errors = [];
|
||||
let entries = [];
|
||||
|
||||
try {
|
||||
entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
return { apps, errors: [{ directory: appsBasePath, error: error.message }] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = path.join(appsBasePath, entry.name, 'app.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = normalizeManifest(JSON.parse(raw), entry.name, environment);
|
||||
apps.push(manifest);
|
||||
} catch (error) {
|
||||
errors.push({ directory: entry.name, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
apps.sort((left, right) => left.name.localeCompare(right.name, 'fr'));
|
||||
return { apps, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualise le cache mémoire utilisé par les routes et les contrôles de santé.
|
||||
* 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, 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}`);
|
||||
}
|
||||
logger.info(`[app-registry] ${result.apps.length} application(s) déployée(s) détectée(s)`);
|
||||
|
||||
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.
|
||||
*/
|
||||
function writeAppManifest({ appsBasePath, environment, app }) {
|
||||
const directory = app.directory || app.id;
|
||||
const appDirectory = path.join(appsBasePath, directory);
|
||||
const normalized = normalizeManifest({ ...app, directory: undefined, healthCheckUrl: undefined }, directory, environment);
|
||||
const manifestPath = path.join(appDirectory, 'app.json');
|
||||
const temporaryPath = `${manifestPath}.tmp`;
|
||||
|
||||
fs.mkdirSync(appDirectory, { recursive: true });
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify({ ...normalized, directory: undefined, healthCheckUrl: undefined }, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(temporaryPath, manifestPath);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function findAppDirectoryByRepo(config, repositoryName) {
|
||||
const app = findAppByRepo(config, repositoryName);
|
||||
return app ? app.directory : null;
|
||||
}
|
||||
|
||||
function findAppByRepo(config, repositoryName) {
|
||||
const { apps } = refreshApps(config);
|
||||
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
discoverApps,
|
||||
refreshApps,
|
||||
refreshAppsIfStale,
|
||||
writeAppManifest,
|
||||
findAppDirectoryByRepo,
|
||||
findAppByRepo,
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
|
||||
const MAX_ALERTS = 100;
|
||||
|
||||
function formatEmailAlert(alert) {
|
||||
return [
|
||||
'Une validation CI a échoué sur l’infrastructure Santinova.',
|
||||
'',
|
||||
`Dépôt : ${alert.repository}`,
|
||||
`Workflow : ${alert.workflow}`,
|
||||
`Conclusion : ${alert.conclusion}`,
|
||||
`Commit : ${alert.commit}`,
|
||||
`Date : ${alert.occurredAt}`,
|
||||
alert.url ? `Détail : ${alert.url}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function enqueueDsiEmail(alert) {
|
||||
if (!config.dsiAlerts.email) return false;
|
||||
const queueRoot = config.dsiAlerts.emailQueue;
|
||||
const safeId = String(alert.id).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const queueFile = queueRoot.endsWith('.json')
|
||||
? queueRoot
|
||||
: path.join(queueRoot, `${safeId}.json`);
|
||||
const temporary = `${queueFile}.${process.pid}.tmp`;
|
||||
const payload = {
|
||||
id: alert.id,
|
||||
to: config.dsiAlerts.email,
|
||||
subject: `[Santinova] Échec CI — ${alert.repository}`,
|
||||
text: formatEmailAlert(alert),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(queueFile), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(temporary, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(temporary, queueFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeWorkflowFailure(payload, deliveryId = null) {
|
||||
if (payload?.action !== 'completed') return null;
|
||||
const run = payload?.workflow_run || {};
|
||||
const conclusion = String(run.conclusion || '').toLowerCase();
|
||||
if (!conclusion || conclusion === 'success') return null;
|
||||
const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu';
|
||||
const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu';
|
||||
return {
|
||||
id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`,
|
||||
repository,
|
||||
workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI',
|
||||
commit,
|
||||
conclusion,
|
||||
url: run.html_url || null,
|
||||
occurredAt: run.completed_at || new Date().toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
dsiNotificationSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readCiAlerts() {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCiAlert(alert) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id);
|
||||
alerts.unshift(alert);
|
||||
const temporary = `${ALERTS_FILE}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 });
|
||||
fs.renameSync(temporary, ALERTS_FILE);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function notifyDsi(alert) {
|
||||
if (enqueueDsiEmail(alert)) return true;
|
||||
if (!config.dsiAlerts.token) {
|
||||
console.error('[CI Alert] Aucun canal direct DSI configuré : e-mail et IA_REFERENTIEL_TOKEN absents.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${config.dsiAlerts.token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: alert.repository,
|
||||
workflow: alert.workflow,
|
||||
commit: alert.commit,
|
||||
conclusion: alert.conclusion,
|
||||
url: alert.url,
|
||||
occurredAt: alert.occurredAt,
|
||||
deliveryId: alert.id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[CI Alert] Notification DSI refusée (${response.status}).`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CI Alert] Notification DSI impossible:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi, enqueueDsiEmail };
|
||||
@@ -1,46 +1,246 @@
|
||||
/*
|
||||
* Configuration du dashboard.
|
||||
*
|
||||
* Les métadonnées des applications ne sont volontairement pas définies ici.
|
||||
* Elles sont lues exclusivement depuis /opt/manus-deploy/apps/<app>/app.json
|
||||
* par app-registry.js, ce qui évite les divergences entre dashboard et portail.
|
||||
*/
|
||||
const environment = process.env.DASHBOARD_ENV || 'recette';
|
||||
|
||||
if (!['recette', 'prod'].includes(environment)) {
|
||||
throw new Error('DASHBOARD_ENV doit valoir "recette" ou "prod"');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
port: Number.parseInt(process.env.PORT, 10) || 3001,
|
||||
port: process.env.PORT || 3001,
|
||||
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
|
||||
jwtExpiry: '24h',
|
||||
environment,
|
||||
|
||||
// Authentification
|
||||
auth: {
|
||||
username: process.env.ADMIN_USERNAME || 'adminItinova',
|
||||
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
|
||||
},
|
||||
|
||||
// Gitea
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || 'http://gitea:3000',
|
||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||
password: process.env.GITEA_PASSWORD || 'Itinova69!',
|
||||
token: process.env.GITEA_TOKEN || null,
|
||||
productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org',
|
||||
productionToken: process.env.GITEA_TOKEN_PROD || process.env.GITEA_TOKEN || null,
|
||||
},
|
||||
|
||||
dsiAlerts: {
|
||||
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',
|
||||
},
|
||||
|
||||
// Applications config
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
|
||||
healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
|
||||
|
||||
// Cache mémoire hydraté au démarrage puis après chaque création d'application.
|
||||
apps: [],
|
||||
// Health check interval (ms)
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
|
||||
// Apps configuration
|
||||
apps: [
|
||||
{
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
description: 'Application de gestion des contacts Itinova',
|
||||
directory: 'itinova-contacts',
|
||||
giteaRepo: 'itinova-contacts',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://contacts.recette.santinova-soft.org',
|
||||
prod: 'https://contacts.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-contacts',
|
||||
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'gestion-at-v2',
|
||||
name: 'Gestion des AT V2',
|
||||
description: 'Application de gestion des accidents du travail Itinova',
|
||||
directory: 'gestion-at-v2',
|
||||
giteaRepo: 'gestion-at-v2',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://at.recette.santinova-soft.org',
|
||||
prod: 'https://at.santinova-soft.org',
|
||||
},
|
||||
containerName: 'gestion-at',
|
||||
healthCheckUrl: 'https://at.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
description: 'Gestionnaire de podcasts pour les établissements Itinova',
|
||||
directory: 'itinova-podcasts',
|
||||
giteaRepo: 'itinova-podcasts',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://podcasts.recette.santinova-soft.org',
|
||||
prod: 'https://podcasts.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-podcasts',
|
||||
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'veille-reglementaire',
|
||||
name: 'Veille Réglementaire',
|
||||
description: 'Application de veille réglementaire et appels à projets',
|
||||
directory: 'veille-reglementaire',
|
||||
giteaRepo: 'veille-reglementaire',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://veille.recette.santinova-soft.org',
|
||||
prod: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
containerName: 'veille-reglementaire-recette',
|
||||
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Itinova Gestion de Flotte',
|
||||
description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements',
|
||||
directory: 'itinova-vehicle-exchange',
|
||||
giteaRepo: 'itinova-vehicle-exchange',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://flotte.recette.santinova-soft.org',
|
||||
prod: 'https://flotte.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-vehicle-exchange',
|
||||
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'pilotage-masse-salariale',
|
||||
name: 'Pilotage Masse Salariale',
|
||||
description: 'Application de pilotage de la masse salariale Itinova',
|
||||
directory: 'pilotage-masse-salariale',
|
||||
giteaRepo: 'pilotage-masse-salariale',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
prod: 'https://pilotage-ms.santinova-soft.org',
|
||||
},
|
||||
containerName: 'pilotage-masse-salariale-app',
|
||||
healthCheckUrl: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'sonum',
|
||||
name: 'SONUM',
|
||||
description: 'Cartographie des Solutions Numériques FEHAP',
|
||||
directory: 'sonum',
|
||||
giteaRepo: 'sonum',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://sonum.recette.santinova-soft.org',
|
||||
prod: 'https://sonum.santinova-soft.org',
|
||||
},
|
||||
containerName: 'sonum',
|
||||
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'demat-facturation-dsi',
|
||||
name: 'Démat. Facturation DSI',
|
||||
description: 'Dématérialisation des factures DSI — Import email/dossier, analyse IA, export SharePoint/PDF, validation BAP',
|
||||
directory: 'demat-facturation-dsi',
|
||||
giteaRepo: 'demat-facturation-dsi',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
prod: 'https://demat-facturation.santinova-soft.org',
|
||||
},
|
||||
containerName: 'demat-facturation-app',
|
||||
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'facturation-santinova',
|
||||
name: 'Facturation Santinova',
|
||||
description: 'Application de gestion de la facturation Santinova',
|
||||
directory: 'facturation-santinova',
|
||||
giteaRepo: 'facturation-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://facturation.recette.santinova-soft.org',
|
||||
prod: 'https://facturation.santinova-soft.org',
|
||||
},
|
||||
containerName: 'facturation-santinova-app',
|
||||
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
|
||||
port: 3001,
|
||||
category: 'SANTINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'formation-manager-itinova',
|
||||
name: 'Formation Manager',
|
||||
description: 'Gestion des Formations Itinova',
|
||||
directory: 'formation-manager-itinova',
|
||||
giteaRepo: 'formation-manager-itinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://formations.recette.santinova-soft.org',
|
||||
prod: 'https://formations.itinova.org',
|
||||
},
|
||||
containerName: 'formation-manager-itinova',
|
||||
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'portail-santinova',
|
||||
name: 'Portail Applicatif',
|
||||
description: 'Portail applicatif Santinova — accès centralisé aux applications',
|
||||
directory: 'portail-santinova',
|
||||
giteaRepo: 'portail-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://portail.recette.santinova-soft.org',
|
||||
prod: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
containerName: 'portail-santinova',
|
||||
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard Recette',
|
||||
description: 'Dashboard de gestion de l\'infrastructure Manus',
|
||||
directory: 'manus-dashboard',
|
||||
giteaRepo: 'manus-dashboard',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://dashboard.recette.santinova-soft.org',
|
||||
prod: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
containerName: 'manus-dashboard',
|
||||
healthCheckUrl: 'https://dashboard.recette.santinova-soft.org',
|
||||
port: 3001,
|
||||
category: 'INFRA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-budget-si',
|
||||
name: 'Gestion Budget Informatique',
|
||||
description: 'Application de gestion du budget informatique DSI Itinova',
|
||||
directory: 'itinova-budget-si',
|
||||
giteaRepo: 'itinova-budget-si',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://budget-si.recette.santinova-soft.org',
|
||||
prod: 'https://budget-si.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-budget-si-app',
|
||||
healthCheckUrl: 'https://budget-si.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -139,38 +139,11 @@ function gitPull(appConfig) {
|
||||
/**
|
||||
* Démarrer un conteneur Docker
|
||||
*/
|
||||
async function startContainer(containerName, appId) {
|
||||
async function startContainer(containerName) {
|
||||
try {
|
||||
// Tenter d'abord un docker start (conteneur existant mais arrêté)
|
||||
const container = docker.getContainer(containerName);
|
||||
const info = await container.inspect().catch(() => null);
|
||||
if (info) {
|
||||
// Le conteneur existe, on le démarre
|
||||
if (!info.State.Running) {
|
||||
await container.start();
|
||||
}
|
||||
return { success: true, message: `Conteneur ${containerName} démarré` };
|
||||
}
|
||||
// Le conteneur n'existe pas : docker compose up dans le répertoire de l'app
|
||||
const id = appId || containerName;
|
||||
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||
const appDir = path.join(appsBasePath, id);
|
||||
const fs = require('fs');
|
||||
const composeFile = fs.existsSync(path.join(appDir, 'docker-compose.yml'))
|
||||
? path.join(appDir, 'docker-compose.yml')
|
||||
: fs.existsSync(path.join(appDir, 'docker-compose.prod.yml'))
|
||||
? path.join(appDir, 'docker-compose.prod.yml')
|
||||
: null;
|
||||
if (!composeFile) {
|
||||
return { success: false, message: `Aucun docker-compose.yml trouvé dans ${appDir}` };
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(`docker compose -f ${composeFile} up -d`, { cwd: appDir }, (err, stdout, stderr) => {
|
||||
if (err) reject(new Error(stderr || err.message));
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
return { success: true, message: `Conteneur ${containerName} créé et démarré via docker compose` };
|
||||
await container.start();
|
||||
return { success: true, message: `Conteneur ${containerName} démarré` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@ const axios = require('axios');
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
|
||||
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
||||
// Utiliser le token API si disponible, sinon auth basique
|
||||
const giteaClientHeaders = config.gitea.token
|
||||
? { 'Authorization': `token ${config.gitea.token}` }
|
||||
: {};
|
||||
|
||||
const giteaClient = axios.create({
|
||||
baseURL: `${config.gitea.url}/api/v1`,
|
||||
headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
|
||||
...(config.gitea.token
|
||||
? { headers: giteaClientHeaders }
|
||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
||||
),
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -61,120 +70,6 @@ async function getBranches(owner, repo) {
|
||||
}
|
||||
}
|
||||
|
||||
function toDurationSeconds(startedAt, completedAt) {
|
||||
if (!startedAt || !completedAt) return null;
|
||||
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
|
||||
}
|
||||
|
||||
function normalizeWorkflowRun(run) {
|
||||
// Gitea 1.25 renvoie started_at/completed_at (et non les champs GitHub).
|
||||
// Les aliases conservent la compatibilité avec d’éventuelles versions futures.
|
||||
const startedAt = run.started_at || run.run_started_at || null;
|
||||
const completedAt = run.completed_at || run.run_completed_at || run.updated_at || null;
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name || run.workflow_id || 'Validation CI',
|
||||
status: run.status || 'unknown',
|
||||
conclusion: run.conclusion || null,
|
||||
event: run.event || null,
|
||||
commit: run.head_sha || null,
|
||||
createdAt: run.created_at || null,
|
||||
startedAt,
|
||||
completedAt,
|
||||
durationSeconds: toDurationSeconds(startedAt, completedAt),
|
||||
url: run.html_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkflowRuns(owner, repo, limit = 10) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, { params: { limit, page: 1 } });
|
||||
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkflowRunForCommit(owner, repo, commitHash) {
|
||||
const runs = await getWorkflowRuns(owner, repo, 30);
|
||||
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
|
||||
}
|
||||
|
||||
/** Retourne la médiane d'une série numérique non vide. */
|
||||
function median(values) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les derniers runs CI sans conclure à une tendance avec un échantillon insuffisant.
|
||||
*/
|
||||
function calculateCiSummary(runs) {
|
||||
const completed = runs.filter((run) => run.status === 'completed');
|
||||
const successful = completed.filter((run) => run.conclusion === 'success');
|
||||
const durations = completed.map((run) => run.durationSeconds).filter(Number.isFinite);
|
||||
const windowSize = Math.floor(durations.length / 2);
|
||||
const recent = windowSize >= 2 ? durations.slice(0, windowSize) : [];
|
||||
const previous = windowSize >= 2 ? durations.slice(windowSize, windowSize * 2) : [];
|
||||
const recentMedian = median(recent);
|
||||
const previousMedian = median(previous);
|
||||
const changePercent = previousMedian && recentMedian !== null
|
||||
? Math.round(((recentMedian - previousMedian) / previousMedian) * 100)
|
||||
: null;
|
||||
const trend = changePercent === null ? 'unknown'
|
||||
: changePercent <= -10 ? 'faster'
|
||||
: changePercent >= 10 ? 'slower'
|
||||
: 'stable';
|
||||
return {
|
||||
sampleSize: completed.length,
|
||||
successful: successful.length,
|
||||
failed: completed.length - successful.length,
|
||||
successRate: completed.length ? Math.round((successful.length / completed.length) * 100) : null,
|
||||
medianDurationSeconds: median(durations),
|
||||
recentMedianDurationSeconds: recentMedian,
|
||||
previousMedianDurationSeconds: previousMedian,
|
||||
trend,
|
||||
changePercent,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkflowRunSummary(owner, repo) {
|
||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||
}
|
||||
|
||||
async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) {
|
||||
if (!token) return { present: false, url: null, version: null };
|
||||
const client = axios.create({
|
||||
baseURL: `${baseUrl}/api/v1`,
|
||||
headers: { Authorization: `token ${token}` },
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 7000,
|
||||
});
|
||||
try {
|
||||
const [repoResponse, commitsResponse] = await Promise.all([
|
||||
client.get(`/repos/${owner}/${repo}`),
|
||||
client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }),
|
||||
]);
|
||||
const commit = commitsResponse.data?.[0];
|
||||
const sha = commit?.sha?.substring(0, 7) || null;
|
||||
const date = commit?.commit?.author?.date
|
||||
? new Date(commit.commit.author.date).toLocaleDateString('fr-FR')
|
||||
: null;
|
||||
return {
|
||||
present: Boolean(repoResponse.data?.id),
|
||||
url: `${publicBaseUrl}/${owner}/${repo}`,
|
||||
version: sha && date ? `${sha} (${date})` : sha,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message);
|
||||
return { present: false, url: null, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
@@ -218,12 +113,6 @@ module.exports = {
|
||||
getRepo,
|
||||
getCommits,
|
||||
getBranches,
|
||||
getWorkflowRuns,
|
||||
getWorkflowRunSummary,
|
||||
getWorkflowRunForCommit,
|
||||
getRepoSnapshot,
|
||||
normalizeWorkflowRun,
|
||||
calculateCiSummary,
|
||||
listRepos,
|
||||
createRepo,
|
||||
giteaClient,
|
||||
|
||||
@@ -3,175 +3,125 @@ const https = require('https');
|
||||
const config = require('./config');
|
||||
const { getContainerInfo } = require('./docker');
|
||||
|
||||
// État volatile : il est régénéré au démarrage par les contrôles de santé.
|
||||
// Store pour les statuts des apps
|
||||
const appStatuses = new Map();
|
||||
|
||||
// Store pour les logs de déploiement
|
||||
const deploymentLogs = [];
|
||||
const giteaCommits = new Map();
|
||||
const MAX_DEPLOYMENT_LOGS = 50;
|
||||
const MAX_CONCURRENT_CHECKS = 4;
|
||||
|
||||
// Les certificats internes peuvent être auto-signés. Les URLs sont déclarées
|
||||
// dans les manifests app.json et ne sont jamais construites depuis une entrée utilisateur.
|
||||
// Store pour les commits Gitea
|
||||
const giteaCommits = new Map();
|
||||
|
||||
// Agent HTTPS qui ignore les certificats auto-signés
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
let runningHealthCheck = null;
|
||||
|
||||
// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed"
|
||||
// (ils ont été interrompus lors du dernier redémarrage du Dashboard)
|
||||
function cleanupOrphanedDeployments() {
|
||||
const orphans = deploymentLogs.filter((entry) => entry.status === 'running');
|
||||
for (const entry of orphans) {
|
||||
entry.status = 'failed';
|
||||
entry.message = 'Déploiement interrompu (redémarrage du Dashboard)';
|
||||
entry.endedAt = new Date().toISOString();
|
||||
}
|
||||
const orphans = deploymentLogs.filter((l) => l.status === 'running');
|
||||
if (orphans.length > 0) {
|
||||
console.log(`[healthcheck] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
|
||||
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`);
|
||||
orphans.forEach((l) => {
|
||||
l.status = 'failed';
|
||||
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
|
||||
l.endedAt = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Vérifie une URL applicative avec un délai borné. */
|
||||
/**
|
||||
* Vérifier la santé d'une URL
|
||||
*/
|
||||
async function checkUrl(url) {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const start = Date.now();
|
||||
const response = await axios.get(url, {
|
||||
timeout: 8000,
|
||||
timeout: 10000,
|
||||
httpsAgent,
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
return {
|
||||
online: true,
|
||||
statusCode: response.status,
|
||||
responseTime: Date.now() - startedAt,
|
||||
responseTime,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
return {
|
||||
online: false,
|
||||
statusCode: null,
|
||||
responseTime: null,
|
||||
error: error.message,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* de mélanger les états des environnements et divise les appels HTTP inutiles.
|
||||
* Vérifier la santé d'une application
|
||||
*/
|
||||
async function checkApp(appConfig) {
|
||||
const environment = config.environment;
|
||||
const localUrl = appConfig.urls?.[environment];
|
||||
const result = {
|
||||
id: appConfig.id,
|
||||
name: appConfig.name,
|
||||
description: appConfig.description || '',
|
||||
// Préserve la catégorie et les métadonnées IA du manifeste pour l’interface.
|
||||
category: appConfig.category || 'SANTINOVA',
|
||||
ai: appConfig.ai || { engines: [] },
|
||||
description: appConfig.description,
|
||||
urls: appConfig.urls,
|
||||
containerName: appConfig.containerName,
|
||||
lastCheck: new Date().toISOString(),
|
||||
status: 'unknown',
|
||||
container: null,
|
||||
health: { recette: null, prod: null },
|
||||
health: {
|
||||
recette: null,
|
||||
prod: null,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
// Vérifier le conteneur Docker
|
||||
const containerInfo = await getContainerInfo(appConfig.containerName);
|
||||
result.container = containerInfo;
|
||||
result.health[environment] = health;
|
||||
|
||||
// Health check recette
|
||||
if (appConfig.urls.recette) {
|
||||
result.health.recette = await checkUrl(appConfig.urls.recette);
|
||||
}
|
||||
|
||||
// Health check prod
|
||||
if (appConfig.urls.prod) {
|
||||
result.health.prod = await checkUrl(appConfig.urls.prod);
|
||||
}
|
||||
|
||||
// Déterminer le statut global
|
||||
if (!containerInfo || !containerInfo.running) {
|
||||
result.status = 'offline';
|
||||
} else if (health?.online) {
|
||||
} else if (result.health.recette && result.health.recette.online) {
|
||||
result.status = 'online';
|
||||
} else {
|
||||
} else if (containerInfo.running) {
|
||||
result.status = 'error';
|
||||
} else {
|
||||
result.status = 'offline';
|
||||
}
|
||||
|
||||
// Stocker le résultat
|
||||
appStatuses.set(appConfig.id, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Exécute un lot asynchrone avec une concurrence bornée. */
|
||||
async function mapWithConcurrency(items, limit, worker) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
|
||||
async function runWorker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await worker(items[index]);
|
||||
}
|
||||
/**
|
||||
* Lancer les health checks pour toutes les apps
|
||||
*/
|
||||
async function checkAllApps() {
|
||||
const results = [];
|
||||
for (const app of config.apps) {
|
||||
const result = await checkApp(app);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const workerCount = Math.min(Math.max(limit, 1), items.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance les contrôles sans chevauchement : si un cycle est encore actif, les
|
||||
* consommateurs récupèrent sa même promesse au lieu de redoubler les requêtes.
|
||||
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
|
||||
*/
|
||||
function checkAllApps() {
|
||||
if (runningHealthCheck) return runningHealthCheck;
|
||||
|
||||
runningHealthCheck = mapWithConcurrency(config.apps, MAX_CONCURRENT_CHECKS, checkApp)
|
||||
.catch((error) => {
|
||||
console.error('[healthcheck] Échec du cycle :', error.message);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
runningHealthCheck = null;
|
||||
});
|
||||
|
||||
return runningHealthCheck;
|
||||
}
|
||||
|
||||
function addDeploymentLog(appId, log) {
|
||||
const entry = {
|
||||
id: Date.now().toString(),
|
||||
@@ -180,35 +130,53 @@ function addDeploymentLog(appId, log) {
|
||||
...log,
|
||||
};
|
||||
deploymentLogs.unshift(entry);
|
||||
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
|
||||
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
|
||||
deploymentLogs.pop();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour un log de déploiement existant par son id
|
||||
* Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée
|
||||
*/
|
||||
function updateDeploymentLog(entryId, updates) {
|
||||
const entry = deploymentLogs.find((log) => log.id === entryId);
|
||||
if (!entry) return null;
|
||||
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
|
||||
return entry;
|
||||
const entry = deploymentLogs.find((l) => l.id === entryId);
|
||||
if (entry) {
|
||||
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les logs de déploiement
|
||||
*/
|
||||
function getDeploymentLogs(appId = null) {
|
||||
return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
|
||||
if (appId) {
|
||||
return deploymentLogs.filter((l) => l.appId === appId);
|
||||
}
|
||||
return deploymentLogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer le statut d'une app
|
||||
*/
|
||||
function getAppStatus(appId) {
|
||||
return appStatuses.get(appId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les statuts
|
||||
*/
|
||||
function getAllStatuses() {
|
||||
return Array.from(appStatuses.values());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkUrl,
|
||||
getActiveAiConfiguration,
|
||||
checkApp,
|
||||
checkAllApps,
|
||||
mapWithConcurrency,
|
||||
addDeploymentLog,
|
||||
updateDeploymentLog,
|
||||
getDeploymentLogs,
|
||||
|
||||
@@ -29,12 +29,6 @@ app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
// Les portails sont servis depuis un sous-domaine distinct. Seules les routes
|
||||
// publiques peuvent être lues depuis cette origine ; l’API authentifiée reste inchangée.
|
||||
app.use('/api/public', (req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
next();
|
||||
});
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
@@ -105,9 +99,6 @@ async function runHealthChecks() {
|
||||
|
||||
// Démarrage
|
||||
async function start() {
|
||||
if (!config.gitea.token) {
|
||||
throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.');
|
||||
}
|
||||
await initAuth();
|
||||
|
||||
// Charger les applications dynamiques
|
||||
|
||||
@@ -2,32 +2,18 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { authenticate, authMiddleware } = require('./auth');
|
||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea');
|
||||
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
|
||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||
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)) {
|
||||
@@ -270,7 +256,7 @@ router.get('/apps-config/stacks', authMiddleware, (req, res) => {
|
||||
// Créer une nouvelle application
|
||||
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
|
||||
try {
|
||||
const { name, description, subdomain, stack, port, needsDb, dbType, category, sourceType, giteaRepoUrl } = req.body;
|
||||
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !subdomain || !stack || !sourceType) {
|
||||
@@ -336,7 +322,6 @@ router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), asy
|
||||
port: port ? parseInt(port) : null,
|
||||
needsDb: needsDb === 'true' || needsDb === true,
|
||||
dbType: dbType || 'mysql',
|
||||
category: category || 'SANTINOVA',
|
||||
sourceType,
|
||||
giteaRepoUrl,
|
||||
zipFilePath: req.file ? req.file.path : null,
|
||||
@@ -413,23 +398,6 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const runs = await getWorkflowRuns(req.params.owner, req.params.repo, 10);
|
||||
res.json(runs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gitea/repos/:owner/:repo/actions/summary', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
res.json(await getWorkflowRunSummary(req.params.owner, req.params.repo));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
||||
router.get('/public/status', async (req, res) => {
|
||||
@@ -459,9 +427,8 @@ router.get('/public/apps', (req, res) => {
|
||||
// getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué
|
||||
// On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse
|
||||
const statuses = getAllStatuses();
|
||||
// Le portail répartit lui-même les applications par onglet, y compris INFRA.
|
||||
// L’API expose donc toutes les applications du registre local, sans en masquer.
|
||||
const publicApps = config.apps
|
||||
.filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard)
|
||||
.map((a) => {
|
||||
const status = statuses.find((s) => s.id === a.id);
|
||||
return {
|
||||
@@ -515,7 +482,7 @@ router.post('/apps/:id/start', authMiddleware, async (req, res) => {
|
||||
const app = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
||||
const containerName = app.containerName || app.id;
|
||||
const result = await startContainer(containerName, app.id);
|
||||
const result = await startContainer(containerName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -556,63 +523,17 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ci-alerts', authMiddleware, (req, res) => {
|
||||
res.json(readCiAlerts());
|
||||
});
|
||||
|
||||
const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh';
|
||||
const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json';
|
||||
const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request';
|
||||
|
||||
function readGiteaRotationStatus() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
function runGiteaRotation(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => {
|
||||
if (error) return reject(new Error((stderr || stdout || error.message).trim()));
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => {
|
||||
res.json(readGiteaRotationStatus());
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await runGiteaRotation(['prepare', '--force']);
|
||||
res.json(readGiteaRotationStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message, status: readGiteaRotationStatus() });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => {
|
||||
const status = readGiteaRotationStatus();
|
||||
if (status.state !== 'pending_approval') {
|
||||
return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||
const { exec } = require('child_process');
|
||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
||||
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = config.gitea.productionUrl;
|
||||
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
||||
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
||||
const GITEA_TOKEN_PRD = config.gitea.productionToken;
|
||||
|
||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||
function getInventoryApps() {
|
||||
@@ -623,6 +544,41 @@ function getInventoryApps() {
|
||||
}));
|
||||
}
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||
return new Promise((resolve) => {
|
||||
const authHeader = token
|
||||
? `-H "Authorization: token ${token}"`
|
||||
: `-u "${GITEA_USER_INV}:${pass}"`;
|
||||
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
||||
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
if (!data.id) return resolve({ present: false, url: null, version: null });
|
||||
// Récupérer le dernier commit
|
||||
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
||||
let version = null;
|
||||
try {
|
||||
const commits = JSON.parse(stdout2 || '[]');
|
||||
if (commits.length > 0) {
|
||||
const c = commits[0];
|
||||
const sha = c.sha ? c.sha.substring(0, 7) : null;
|
||||
const date = c.commit && c.commit.author && c.commit.author.date
|
||||
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
|
||||
version = sha && date ? `${sha} (${date})` : sha;
|
||||
}
|
||||
} catch(e) {}
|
||||
const displayUrl = publicBaseUrl || baseUrl;
|
||||
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
||||
});
|
||||
} catch(e) {
|
||||
resolve({ present: false, url: null, version: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const owner = GITEA_USER_INV;
|
||||
@@ -630,8 +586,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
const results = await Promise.all(
|
||||
INVENTORY_APPS.map(async (app) => {
|
||||
const [repoRecette, repoProd] = await Promise.all([
|
||||
getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
|
||||
getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
|
||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
|
||||
@@ -5,13 +5,9 @@
|
||||
*/
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const { findAppByRepo } = require('./app-registry');
|
||||
const { getWorkflowRunForCommit } = require('./gitea');
|
||||
const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,74 +15,32 @@ const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
||||
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const DASHBOARD_APP_ID = 'manus-dashboard';
|
||||
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
|
||||
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||||
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
|
||||
|
||||
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
|
||||
const REPO_TO_APP_MAP = {
|
||||
'itinova-contacts': 'itinova-contacts',
|
||||
'gestion-at-v2': 'gestion-at-v2',
|
||||
'itinova-podcasts': 'itinova-podcasts',
|
||||
'veille-reglementaire': 'veille-reglementaire',
|
||||
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
|
||||
'manus-dashboard': 'manus-dashboard',
|
||||
'itinova-budget-si': 'itinova-budget-si',
|
||||
'facturation-santinova': 'facturation-santinova',
|
||||
'demat-facturation-dsi': 'demat-facturation-dsi',
|
||||
};
|
||||
|
||||
// Déploiements en cours (évite les doubles déclenchements)
|
||||
const deployingApps = new Set();
|
||||
|
||||
/**
|
||||
* Un conteneur ne peut pas se recréer lui-même : il interromprait son propre
|
||||
* client Docker. Le service systemd hôte surveille la demande atomique créée ici.
|
||||
*/
|
||||
function isHostManagedDeployment(appName) {
|
||||
return appName === DASHBOARD_APP_ID;
|
||||
}
|
||||
|
||||
function requestDashboardHostDeployment({ branch, commitHash, committer }) {
|
||||
const temporaryRequest = `${DASHBOARD_DEPLOY_REQUEST}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporaryRequest, JSON.stringify({ branch, commitHash, committer, requestedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
|
||||
}
|
||||
|
||||
function shouldRequireCi(app) {
|
||||
return app?.ci?.required === true;
|
||||
}
|
||||
|
||||
function saveDeploymentStatus(appName, status) {
|
||||
try {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify(status, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[Webhook] Erreur sauvegarde statut:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delay) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
async function waitForSuccessfulCi(app, commitHash) {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
|
||||
let lastRun = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
|
||||
if (lastRun?.status === 'completed') {
|
||||
return {
|
||||
allowed: lastRun.conclusion === 'success',
|
||||
run: lastRun,
|
||||
reason: lastRun.conclusion === 'success' ? null : `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
|
||||
};
|
||||
}
|
||||
await wait(CI_GATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
run: lastRun,
|
||||
reason: lastRun ? 'La CI n’a pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
|
||||
};
|
||||
}
|
||||
|
||||
function verifyGiteaSignature(req) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
|
||||
return true;
|
||||
}
|
||||
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
|
||||
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
|
||||
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
|
||||
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
|
||||
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
|
||||
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
|
||||
@@ -112,14 +66,10 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
|
||||
}
|
||||
|
||||
const env = { ...process.env, APPS_BASE_PATH };
|
||||
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
|
||||
let output = '';
|
||||
|
||||
// Les deux serveurs partagent le module, mais leurs scripts de déploiement
|
||||
// n’attendent pas le même second argument : recette attend son environnement,
|
||||
// production attend la branche Git. Cette sélection évite tout mélange.
|
||||
const deployTarget = config.environment === 'prod' ? 'main' : 'recette';
|
||||
// execFile évite toute interprétation shell du nom de dossier issu du manifeste.
|
||||
const child = execFile('bash', [DEPLOY_SCRIPT, appName, deployTarget], { env, timeout: 600000 });
|
||||
const child = exec(cmd, { env, timeout: 600000 });
|
||||
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
|
||||
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
|
||||
|
||||
@@ -130,30 +80,16 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
|
||||
if (broadcast) {
|
||||
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
|
||||
}
|
||||
saveDeploymentStatus(appName, {
|
||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
|
||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||||
}, null, 2));
|
||||
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
async function gateAndDeploy(app, branch, commitHash, committer, broadcast) {
|
||||
if (shouldRequireCi(app)) {
|
||||
const ci = await waitForSuccessfulCi(app, commitHash);
|
||||
if (!ci.allowed) {
|
||||
const status = { app: app.directory, branch, commit: commitHash, committer, status: 'blocked', timestamp: new Date().toISOString(), ci: ci.run, reason: ci.reason };
|
||||
saveDeploymentStatus(app.directory, status);
|
||||
broadcast?.({ type: 'deploy_blocked', data: status });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isHostManagedDeployment(app.directory)) {
|
||||
requestDashboardHostDeployment({ branch, commitHash, committer });
|
||||
return;
|
||||
}
|
||||
runDeploy(app.directory, branch, commitHash, committer, broadcast);
|
||||
}
|
||||
|
||||
// POST /api/webhook/gitea
|
||||
router.post('/gitea', (req, res) => {
|
||||
if (!verifyGiteaSignature(req)) {
|
||||
@@ -161,22 +97,6 @@ router.post('/gitea', (req, res) => {
|
||||
return res.status(401).json({ error: 'Signature invalide' });
|
||||
}
|
||||
const event = req.headers['x-gitea-event'] || 'unknown';
|
||||
if (event === 'workflow_run') {
|
||||
const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null);
|
||||
if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' });
|
||||
saveCiAlert(alert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: alert });
|
||||
res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id });
|
||||
setImmediate(async () => {
|
||||
const sent = await notifyDsi(alert);
|
||||
if (sent) {
|
||||
const notifiedAlert = { ...alert, dsiNotificationSent: true };
|
||||
saveCiAlert(notifiedAlert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event !== 'push') {
|
||||
return res.status(200).json({ message: `Événement ${event} ignoré` });
|
||||
}
|
||||
@@ -188,21 +108,15 @@ router.post('/gitea', (req, res) => {
|
||||
|
||||
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
|
||||
|
||||
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
|
||||
const app = findAppByRepo(config, repoName);
|
||||
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
|
||||
const appName = REPO_TO_APP_MAP[repoName];
|
||||
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
|
||||
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
|
||||
|
||||
res.status(202).json({
|
||||
message: shouldRequireCi(app) ? `Validation CI requise avant déploiement de ${app.directory}` : `Déploiement de ${app.directory} déclenché`,
|
||||
commit: commitHash,
|
||||
branch,
|
||||
ciRequired: shouldRequireCi(app),
|
||||
});
|
||||
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
|
||||
|
||||
// Récupérer la fonction broadcast si disponible globalement
|
||||
const broadcastFn = global.wsBroadcast || null;
|
||||
setImmediate(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
|
||||
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
|
||||
});
|
||||
|
||||
// GET /api/webhook/status/:appName
|
||||
@@ -230,6 +144,3 @@ router.get('/status', (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
||||
module.exports.shouldRequireCi = shouldRequireCi;
|
||||
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
* Tests de non-régression du registre d'applications.
|
||||
* Le registre doit faire de app.json l'unique source de vérité des applications déployées.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { discoverApps, refreshApps, refreshAppsIfStale } = require('../src/app-registry');
|
||||
|
||||
function writeManifest(basePath, directory, manifest) {
|
||||
const appPath = path.join(basePath, directory);
|
||||
fs.mkdirSync(appPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(appPath, 'app.json'), JSON.stringify(manifest), 'utf8');
|
||||
}
|
||||
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'app-registry-'));
|
||||
|
||||
try {
|
||||
writeManifest(sandbox, 'contacts', {
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
category: 'ITINOVA',
|
||||
containerName: 'itinova-contacts',
|
||||
image: 'images/itinova-contacts.png',
|
||||
ai: {
|
||||
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }],
|
||||
activeConfigurationPath: '/api/ai/active-configuration',
|
||||
},
|
||||
urls: {
|
||||
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', {
|
||||
id: 'app-invalide',
|
||||
name: 'Application invalide',
|
||||
category: 'INCONNUE',
|
||||
urls: { recette: 'https://invalide.recette.santinova-soft.org' },
|
||||
});
|
||||
writeManifest(sandbox, 'ia-invalide', {
|
||||
id: 'app-ia-invalide',
|
||||
name: 'Application IA invalide',
|
||||
category: 'ITINOVA',
|
||||
ai: { engines: [{ provider: 'Google Gemini' }] },
|
||||
urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' },
|
||||
});
|
||||
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, 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.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,
|
||||
environment: 'recette',
|
||||
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
|
||||
};
|
||||
const refresh = refreshApps(config);
|
||||
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 {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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');
|
||||
@@ -1,22 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const { normalizeWorkflowFailure } = require('../src/ci-alerts');
|
||||
|
||||
const failure = normalizeWorkflowFailure({
|
||||
action: 'completed',
|
||||
repository: { full_name: 'manus-admin/referentiel-ia' },
|
||||
workflow: { name: 'Validation CI' },
|
||||
workflow_run: {
|
||||
id: 42,
|
||||
conclusion: 'failure',
|
||||
head_sha: '1234567890abcdef',
|
||||
completed_at: '2026-08-21T12:00:00.000Z',
|
||||
html_url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
},
|
||||
}, 'delivery-42');
|
||||
|
||||
assert.equal(failure.id, 'delivery-42');
|
||||
assert.equal(failure.repository, 'manus-admin/referentiel-ia');
|
||||
assert.equal(failure.conclusion, 'failure');
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null);
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null);
|
||||
console.log('OK ci alerts');
|
||||
@@ -1,31 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-email-alert-'));
|
||||
process.env.DSI_ALERT_EMAIL = 'dsi@example.invalid';
|
||||
process.env.CI_ALERT_EMAIL_QUEUE = path.join(temporaryDirectory, 'queued-alert.json');
|
||||
|
||||
const { enqueueDsiEmail } = require('../src/ci-alerts');
|
||||
|
||||
const queued = enqueueDsiEmail({
|
||||
id: 'ci-email-test',
|
||||
repository: 'manus-admin/referentiel-ia',
|
||||
workflow: 'Validation CI',
|
||||
commit: 'abc1234',
|
||||
conclusion: 'failure',
|
||||
url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
occurredAt: '2026-08-21T18:00:00Z',
|
||||
});
|
||||
|
||||
assert.equal(queued, true);
|
||||
const payload = JSON.parse(fs.readFileSync(process.env.CI_ALERT_EMAIL_QUEUE, 'utf8'));
|
||||
assert.equal(payload.to, 'dsi@example.invalid');
|
||||
assert.equal(payload.id, 'ci-email-test');
|
||||
assert.match(payload.subject, /Échec CI/);
|
||||
assert.match(payload.text, /referentiel-ia/);
|
||||
assert.equal(fs.statSync(process.env.CI_ALERT_EMAIL_QUEUE).mode & 0o777, 0o600);
|
||||
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
console.log('OK ci email alert');
|
||||
@@ -1,13 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8');
|
||||
|
||||
assert.match(source, /dashboard-alert-token\.ts prepare/);
|
||||
assert.match(source, /alerts\/ci\/health/);
|
||||
assert.match(source, /dashboard-alert-token\.ts finalize/);
|
||||
assert.match(source, /rollback effectué/);
|
||||
assert.doesNotMatch(source, /GITEA_PASSWORD/);
|
||||
console.log('OK dashboard alert token');
|
||||
@@ -1,14 +0,0 @@
|
||||
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');
|
||||
@@ -1,23 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const files = [
|
||||
'backend/src/config.js',
|
||||
'backend/src/gitea.js',
|
||||
'backend/src/app-creator.js',
|
||||
'backend/src/routes.js',
|
||||
'docker-compose.yml',
|
||||
];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`);
|
||||
assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`);
|
||||
}
|
||||
|
||||
const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8');
|
||||
assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/);
|
||||
assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false);
|
||||
console.log('OK gitea auth');
|
||||
@@ -1,27 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
|
||||
|
||||
const run = normalizeWorkflowRun({
|
||||
id: 42,
|
||||
name: 'Validation',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
head_sha: 'abcdef123456',
|
||||
started_at: '2026-08-18T10:00:10Z',
|
||||
completed_at: '2026-08-18T10:02:15Z',
|
||||
});
|
||||
|
||||
assert.equal(run.commit, 'abcdef123456');
|
||||
assert.equal(run.durationSeconds, 125);
|
||||
assert.equal(run.conclusion, 'success');
|
||||
|
||||
const summary = calculateCiSummary([
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 90 },
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 110 },
|
||||
{ status: 'completed', conclusion: 'failure', durationSeconds: 120 },
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 100 },
|
||||
]);
|
||||
assert.equal(summary.successRate, 75);
|
||||
assert.equal(summary.medianDurationSeconds, 105);
|
||||
assert.equal(summary.trend, 'stable');
|
||||
console.log('OK gitea');
|
||||
@@ -1,40 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
|
||||
// Le test est autonome : aucune dépendance externe ni requête réseau n’est nécessaire.
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function mockAxios(request, parent, isMain) {
|
||||
if (request === 'axios') return { get: async () => ({ status: 200 }) };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
// Le test isole Docker : il valide le contrat de données émis par healthcheck.
|
||||
const dockerPath = require.resolve('../src/docker');
|
||||
require.cache[dockerPath] = {
|
||||
id: dockerPath,
|
||||
filename: dockerPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
getContainerInfo: async () => ({ running: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const { checkApp } = require('../src/healthcheck');
|
||||
|
||||
(async () => {
|
||||
const result = await checkApp({
|
||||
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);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
|
||||
* temps, même lorsqu’un grand nombre d’applications est déclaré.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const { mapWithConcurrency } = require('../src/healthcheck');
|
||||
|
||||
(async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const values = await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 3, async (value) => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
return value * 2;
|
||||
});
|
||||
|
||||
assert.deepEqual(values, [2, 4, 6, 8, 10, 12, 14]);
|
||||
assert.ok(peak <= 3, `concurrence observée ${peak}, maximum autorisé 3`);
|
||||
console.log('OK healthcheck');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8');
|
||||
const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8');
|
||||
const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8');
|
||||
|
||||
assert.match(rotation, /generate-access-token/);
|
||||
assert.match(rotation, /pending_approval/);
|
||||
assert.match(rotation, /GITEA_ROTATION_APPLIED/);
|
||||
assert.match(rotation, /chmod 0600/);
|
||||
assert.match(rotation, /rollback/);
|
||||
assert.doesNotMatch(rotation, /GITEA_PASSWORD/);
|
||||
assert.doesNotMatch(rotation, /trap .*RETURN/);
|
||||
assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/);
|
||||
assert.match(timer, /Persistent=true/);
|
||||
assert.match(approvalPath, /PathExists=.*approval\.request/);
|
||||
console.log('OK token rotation');
|
||||
@@ -1,14 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8');
|
||||
const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
|
||||
|
||||
assert.match(setup, /"events": \["push", "workflow_run"\]/);
|
||||
assert.match(setup, /secrets\.token_hex\(32\)/);
|
||||
assert.match(setup, /method=method/);
|
||||
assert.doesNotMatch(setup, /GITEA_PASSWORD/);
|
||||
assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/);
|
||||
console.log('OK webhook setup');
|
||||
@@ -1,9 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { isHostManagedDeployment, shouldRequireCi } = require('../src/webhook');
|
||||
|
||||
assert.equal(isHostManagedDeployment('manus-dashboard'), true);
|
||||
assert.equal(isHostManagedDeployment('itinova-contacts'), false);
|
||||
assert.equal(shouldRequireCi({ ci: { required: true } }), true);
|
||||
assert.equal(shouldRequireCi({ ci: { required: false } }), false);
|
||||
assert.equal(shouldRequireCi({}), false);
|
||||
console.log('OK webhook');
|
||||
@@ -13,14 +13,10 @@ services:
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- 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=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
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
@@ -33,8 +29,8 @@ services:
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Route HTTPS via dashboard-santinova.santinova-soft.org
|
||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard-santinova.santinova-soft.org`)"
|
||||
# Route HTTPS via dashboard.recette.santinova-soft.org
|
||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.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"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard Production - Gestion des Applications</title>
|
||||
<title>Dashboard Recette - Gestion des Applications</title>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
||||
3030
src/frontend/package-lock.json
generated
3030
src/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -9,17 +9,14 @@ import DockerPage from './pages/DockerPage';
|
||||
import DocumentationPage from './pages/DocumentationPage';
|
||||
import InventairePage from './pages/InventairePage';
|
||||
import TerminalPage from './pages/TerminalPage';
|
||||
import CiPage from './pages/CiPage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import CiAlertBanner from './components/CiAlertBanner';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api';
|
||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [apps, setApps] = useState([]);
|
||||
const [ciAlerts, setCiAlerts] = useState([]);
|
||||
// Vérifier l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
@@ -28,7 +25,6 @@ export default function App() {
|
||||
.then(() => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
@@ -47,22 +43,11 @@ export default function App() {
|
||||
console.error('Erreur chargement apps:', err);
|
||||
}
|
||||
};
|
||||
const fetchCiAlerts = async () => {
|
||||
try {
|
||||
const response = await getCiAlerts();
|
||||
setCiAlerts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement alertes CI:', error);
|
||||
}
|
||||
};
|
||||
// WebSocket handler
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.type === 'health_update') {
|
||||
setApps(data.data);
|
||||
}
|
||||
if (data.type === 'ci_alert') {
|
||||
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
|
||||
}
|
||||
}, []);
|
||||
const { connected: wsConnected } = useWebSocket(
|
||||
authenticated ? handleWsMessage : null
|
||||
@@ -70,7 +55,6 @@ export default function App() {
|
||||
const handleLogin = (data) => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -81,7 +65,6 @@ export default function App() {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
setAuthenticated(false);
|
||||
setApps([]);
|
||||
setCiAlerts([]);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -117,8 +100,6 @@ export default function App() {
|
||||
return <DashboardPage apps={apps} />;
|
||||
case 'apps':
|
||||
return <AppsPage apps={apps} onRefresh={fetchApps} />;
|
||||
case 'ci':
|
||||
return <CiPage apps={apps} />;
|
||||
case 'deployments':
|
||||
return <DeploymentsPage />;
|
||||
case 'gitea':
|
||||
@@ -145,10 +126,7 @@ export default function App() {
|
||||
onLogout={handleLogout}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
<main className="ml-64 p-8">
|
||||
<CiAlertBanner alert={ciAlerts[0]} />
|
||||
{renderPage()}
|
||||
</main>
|
||||
<main className="ml-64 p-8">{renderPage()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 },
|
||||
@@ -181,7 +180,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 {ENVIRONMENT_LABEL_LOWER}
|
||||
Déployez une nouvelle application sur le serveur de recette
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -272,7 +271,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">
|
||||
.{APP_DOMAIN_SUFFIX}
|
||||
.recette.santinova-soft.org
|
||||
</span>
|
||||
</div>
|
||||
{errors.subdomain && (
|
||||
@@ -281,7 +280,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}.{APP_DOMAIN_SUFFIX}
|
||||
URL: https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -546,7 +545,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}.{APP_DOMAIN_SUFFIX}
|
||||
URL: https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -574,7 +573,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}.{APP_DOMAIN_SUFFIX}
|
||||
https://{formData.subdomain}.recette.santinova-soft.org
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import CiBadgeInline from './CiBadgeInline';
|
||||
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@@ -118,9 +117,8 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
|
||||
const latestCommit = commits && commits.length > 0 ? commits[0] : null;
|
||||
|
||||
// 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 : [];
|
||||
// Apps d'infrastructure : portail et dashboard
|
||||
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
|
||||
|
||||
return (
|
||||
<div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
|
||||
@@ -139,7 +137,6 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{app.name}</h3>
|
||||
<StatusBadge status={app.status} />
|
||||
<CiBadgeInline app={app} />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{app.description || app.id}
|
||||
@@ -218,49 +215,7 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
)}
|
||||
|
||||
{/* Info Grid */}
|
||||
<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>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* URLs */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function CiAlertBanner({ alert }) {
|
||||
if (!alert) return null;
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-100 shadow-lg shadow-red-950/20" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="font-semibold">Échec CI — {alert.repository}</p>
|
||||
<p className="mt-1 text-sm text-red-100/80">{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}</p>
|
||||
<p className="mt-1 text-xs text-red-100/60">Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{alert.url && (
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary flex shrink-0 items-center gap-2">
|
||||
Voir le workflow <ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, XCircle, Clock, Shield } from 'lucide-react';
|
||||
import { getGiteaWorkflowRuns } from '../utils/api';
|
||||
import CiRunsModal from './CiRunsModal';
|
||||
|
||||
/**
|
||||
* Badge CI compact affiché dans le header de chaque AppCard.
|
||||
* Récupère le dernier run CI et affiche un badge coloré.
|
||||
* Cliquable : ouvre une modale avec l'historique des runs.
|
||||
*/
|
||||
export default function CiBadgeInline({ app }) {
|
||||
const [lastRun, setLastRun] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
getGiteaWorkflowRuns(owner, repo)
|
||||
.then((res) => {
|
||||
const runs = res.data || [];
|
||||
if (runs.length > 0) setLastRun(runs[0]);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [app.id]);
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-dark-700 text-gray-500 animate-pulse">
|
||||
<Clock className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let badge;
|
||||
|
||||
if (!lastRun) {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-500/15 text-gray-500 border border-gray-500/20 cursor-pointer hover:bg-gray-500/25 transition-colors"
|
||||
title="Cliquer pour voir l'historique CI"
|
||||
>
|
||||
<Shield className="w-3 h-3" /> Pas de CI
|
||||
</span>
|
||||
);
|
||||
} else if (lastRun.conclusion === 'success') {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer hover:bg-emerald-500/25 transition-colors"
|
||||
title={`CI verte — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
} else if (lastRun.conclusion === 'failure') {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/25 transition-colors"
|
||||
title={`CI en échec — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<XCircle className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
badge = (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse cursor-pointer hover:bg-amber-500/25 transition-colors"
|
||||
title={`CI en cours — Run #${lastRun.id} — Cliquer pour l'historique`}
|
||||
>
|
||||
<Clock className="w-3 h-3" /> CI
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{badge}
|
||||
{showModal && <CiRunsModal app={app} onClose={() => setShowModal(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, CheckCircle, XCircle, Clock, ExternalLink, GitCommit, Calendar, Timer } from 'lucide-react';
|
||||
import { getGiteaWorkflowRuns } from '../utils/api';
|
||||
|
||||
/**
|
||||
* Formate une durée en secondes en texte lisible.
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds && seconds !== 0) return '—';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date ISO en format local français.
|
||||
*/
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return '—';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge de conclusion d'un run.
|
||||
*/
|
||||
function RunStatusBadge({ status, conclusion }) {
|
||||
if (conclusion === 'success') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Succès
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (conclusion === 'failure') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<XCircle className="w-3 h-3" /> Échec
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'running' || status === 'waiting') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse">
|
||||
<Clock className="w-3 h-3" /> En cours
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
|
||||
<Clock className="w-3 h-3" /> {conclusion || status || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modale affichant l'historique détaillé des derniers runs CI d'une application.
|
||||
* Ouverte au clic sur un badge CI.
|
||||
*/
|
||||
export default function CiRunsModal({ app, onClose }) {
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
getGiteaWorkflowRuns(owner, repo)
|
||||
.then((res) => {
|
||||
setRuns(res.data || res || []);
|
||||
})
|
||||
.catch(() => setRuns([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [app.id]);
|
||||
|
||||
// Fermer avec Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-dark-900 border border-dark-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Historique CI</h3>
|
||||
<p className="text-sm text-gray-400 mt-0.5">{app.name} — {app.giteaRepo || app.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-dark-700 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-12 text-gray-400">
|
||||
<Clock className="w-5 h-5 animate-spin" />
|
||||
Chargement de l'historique...
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Aucun run CI trouvé pour cette application.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runs.map((run, idx) => (
|
||||
<div
|
||||
key={run.id || idx}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
run.conclusion === 'success'
|
||||
? 'border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10'
|
||||
: run.conclusion === 'failure'
|
||||
? 'border-red-500/20 bg-red-500/5 hover:bg-red-500/10'
|
||||
: 'border-dark-600 bg-dark-800/50 hover:bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono text-gray-500">#{run.id}</span>
|
||||
<RunStatusBadge status={run.status} conclusion={run.conclusion} />
|
||||
</div>
|
||||
{run.url && (
|
||||
<a
|
||||
href={run.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" /> Voir sur Gitea
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>{formatDate(run.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>{formatDuration(run.durationSeconds)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitCommit className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="font-mono">{run.commit ? run.commit.slice(0, 7) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.name && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Workflow : {run.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-dark-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{runs.length} run{runs.length > 1 ? 's' : ''} affichés</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-secondary py-1.5 px-4 text-sm"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { LayoutList, TerminalSquare, ShieldCheck,
|
||||
import { LayoutList, TerminalSquare,
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
@@ -15,7 +15,6 @@ import { LayoutList, TerminalSquare, ShieldCheck,
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'apps', label: 'Applications', icon: Box },
|
||||
{ id: 'ci', label: 'CI / Qualité', icon: ShieldCheck },
|
||||
{ id: 'deployments', label: 'Déploiements', icon: ScrollText },
|
||||
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
|
||||
{ id: 'docker', label: 'Docker', icon: Container },
|
||||
@@ -40,7 +39,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
||||
<p className="text-xs text-gray-500">Production</p>
|
||||
<p className="text-xs text-gray-500">Recette</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Box, RefreshCw, Plus } from 'lucide-react';
|
||||
import AppCard from '../components/AppCard';
|
||||
import AddAppModal from '../components/AddAppModal';
|
||||
import { getApps, getGiteaCommits } from '../utils/api';
|
||||
import { sortAppsWithInfraFirst } from '../utils/appSorting';
|
||||
|
||||
export default function AppsPage({ apps, onRefresh }) {
|
||||
const [commitsMap, setCommitsMap] = useState({});
|
||||
@@ -27,9 +26,6 @@ export default function AppsPage({ apps, onRefresh }) {
|
||||
});
|
||||
}, [apps]);
|
||||
|
||||
// Les services d’infrastructure restent visibles en premier, sans modifier les données API.
|
||||
const sortedApps = sortAppsWithInfraFirst(apps);
|
||||
|
||||
const handleAddSuccess = () => {
|
||||
setShowAddModal(false);
|
||||
// Rafraîchir la liste après un délai pour laisser le temps au health check
|
||||
@@ -90,7 +86,7 @@ export default function AppsPage({ apps, onRefresh }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedApps.map((app) => (
|
||||
{apps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RefreshCw, CheckCircle, XCircle, Clock, TrendingUp, TrendingDown, Minus, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { getApps, getGiteaWorkflowSummary, getGiteaWorkflowRuns } from '../utils/api';
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds && seconds !== 0) return '—';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||||
}
|
||||
|
||||
function TrendIcon({ trend }) {
|
||||
if (trend === 'faster') return <TrendingDown className="w-4 h-4 text-emerald-400" />;
|
||||
if (trend === 'slower') return <TrendingUp className="w-4 h-4 text-amber-400" />;
|
||||
return <Minus className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
function CiBadge({ conclusion }) {
|
||||
if (conclusion === 'success') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Vert
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (conclusion === 'failure') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<XCircle className="w-3 h-3" /> Échec
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
|
||||
<Clock className="w-3 h-3" /> —
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CiPage({ apps }) {
|
||||
const [ciData, setCiData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
|
||||
const fetchCiData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Pour chaque app qui a un giteaRepo, récupérer le résumé CI et le dernier run
|
||||
const results = await Promise.all(
|
||||
(apps || [])
|
||||
.filter(a => a.giteaRepo || a.id)
|
||||
.map(async (app) => {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
try {
|
||||
const [summaryRes, runsRes] = await Promise.all([
|
||||
getGiteaWorkflowSummary(owner, repo),
|
||||
getGiteaWorkflowRuns(owner, repo),
|
||||
]);
|
||||
const summary = summaryRes.data || {};
|
||||
const runs = runsRes.data || [];
|
||||
const lastRun = runs.length > 0 ? runs[0] : null;
|
||||
return {
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
ciRequired: true, // Toutes les apps ont ci.required:true maintenant
|
||||
lastRun,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
ciRequired: false,
|
||||
lastRun: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
setCiData(results);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement CI:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (apps && apps.length > 0) {
|
||||
fetchCiData();
|
||||
}
|
||||
}, [apps]);
|
||||
|
||||
// Stats globales
|
||||
const totalApps = ciData.length;
|
||||
const appsWithCi = ciData.filter(d => d.lastRun);
|
||||
const appsGreen = ciData.filter(d => d.lastRun?.conclusion === 'success');
|
||||
const appsFailed = ciData.filter(d => d.lastRun?.conclusion === 'failure');
|
||||
const globalSuccessRate = appsWithCi.length > 0
|
||||
? Math.round((appsGreen.length / appsWithCi.length) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Shield className="w-7 h-7 text-primary-400" />
|
||||
CI / Qualité
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Gouvernance de l'intégration continue — toutes les applications
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastRefresh && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Mis à jour : {lastRefresh.toLocaleTimeString('fr-FR')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchCiData}
|
||||
disabled={loading}
|
||||
className="btn-secondary py-2 px-4 text-sm flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs globaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Applications CI</span>
|
||||
<Shield className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white mt-2">{appsWithCi.length}<span className="text-lg text-gray-500">/{totalApps}</span></p>
|
||||
<p className="text-xs text-gray-500 mt-1">Avec workflow actif</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Taux de succès</span>
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<p className={`text-3xl font-bold mt-2 ${globalSuccessRate >= 80 ? 'text-emerald-400' : globalSuccessRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{globalSuccessRate}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{appsGreen.length} vertes / {appsFailed.length} en échec</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">CI verte</span>
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-emerald-400 mt-2">{appsGreen.length}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Déploiement autorisé</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">CI en échec</span>
|
||||
<XCircle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-400 mt-2">{appsFailed.length}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Déploiement bloqué</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau détaillé */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-dark-700">
|
||||
<h3 className="text-lg font-semibold text-white">Détail par application</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="p-8 flex items-center justify-center gap-3 text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Chargement des données CI...
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700 bg-dark-800/50">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase">Application</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">CI requise</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Dernier run</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Taux succès</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Durée médiane</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Tendance</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Garde-fou</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{ciData.map((item) => (
|
||||
<tr key={item.appId} className="hover:bg-dark-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-medium text-white">{item.appName}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{item.appId}</span>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.ciRequired ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-primary-500/15 text-primary-400 border border-primary-500/30">
|
||||
<Shield className="w-3 h-3" /> Oui
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<CiBadge conclusion={item.lastRun?.conclusion} />
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.summary?.successRate != null ? (
|
||||
<span className={`font-semibold ${item.summary.successRate >= 80 ? 'text-emerald-400' : item.summary.successRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{item.summary.successRate}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<span className="text-gray-300">
|
||||
{formatDuration(item.summary?.medianDurationSeconds)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TrendIcon trend={item.summary?.trend} />
|
||||
{item.summary?.changePercent != null && (
|
||||
<span className={`text-xs ${item.summary.changePercent <= -10 ? 'text-emerald-400' : item.summary.changePercent >= 10 ? 'text-amber-400' : 'text-gray-500'}`}>
|
||||
{item.summary.changePercent > 0 ? '+' : ''}{item.summary.changePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-center px-4 py-4">
|
||||
{item.lastRun?.conclusion === 'success' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3" /> Autorisé
|
||||
</span>
|
||||
) : item.lastRun?.conclusion === 'failure' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<AlertTriangle className="w-3 h-3" /> Bloqué
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getApps, getSystemInfo } from '../utils/api';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { sortAppsWithInfraFirst } from '../utils/appSorting';
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color, subtext }) {
|
||||
return (
|
||||
@@ -45,7 +44,6 @@ export default function DashboardPage({ apps }) {
|
||||
const onlineApps = apps.filter((a) => a.status === 'online').length;
|
||||
const offlineApps = apps.filter((a) => a.status === 'offline').length;
|
||||
const errorApps = apps.filter((a) => a.status === 'error').length;
|
||||
const sortedApps = sortAppsWithInfraFirst(apps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -101,7 +99,7 @@ export default function DashboardPage({ apps }) {
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Serveur</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
180.149.196.138 (Production)
|
||||
78.138.58.109 (Recette)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
@@ -114,12 +112,12 @@ export default function DashboardPage({ apps }) {
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">Gitea</span>
|
||||
<a
|
||||
href="https://git.santinova-soft.org"
|
||||
href="https://git.recette.santinova-soft.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
git.santinova-soft.org
|
||||
git.recette.santinova-soft.org
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||
@@ -149,12 +147,10 @@ export default function DashboardPage({ apps }) {
|
||||
<p className="text-gray-500 text-sm">Aucune application détectée</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedApps.map((app) => {
|
||||
const isInfra = app.category === "INFRA" || ["portail-santinova", "manus-dashboard"].includes(app.id);
|
||||
return (
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className={`flex items-center justify-between py-3 px-4 rounded-lg ${isInfra ? "bg-amber-950/20 border border-amber-500/30" : "bg-dark-700/50 border border-dark-600/50"}`}
|
||||
className="flex items-center justify-between py-3 px-4 rounded-lg bg-dark-700/50 border border-dark-600/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -167,12 +163,7 @@ export default function DashboardPage({ apps }) {
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
{isInfra && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 mb-0.5">
|
||||
⚙ Infra
|
||||
</span>
|
||||
)}
|
||||
<p className={`text-sm font-medium ${isInfra ? "text-amber-200" : "text-gray-200"}`}>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
@@ -189,8 +180,7 @@ export default function DashboardPage({ apps }) {
|
||||
<StatusBadge status={app.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,16 +19,6 @@ import {
|
||||
Clock,
|
||||
Zap,
|
||||
Package,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
Workflow,
|
||||
ShieldCheck,
|
||||
Route,
|
||||
ArrowDown,
|
||||
CircleAlert,
|
||||
LockKeyhole,
|
||||
ServerCog,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
@@ -55,17 +45,17 @@ const RECETTE = {
|
||||
icon: LayoutDashboard,
|
||||
color: 'blue',
|
||||
name: 'Dashboard',
|
||||
url: 'dashboard-santinova.recette.santinova-soft.org',
|
||||
url: 'dashboard.recette.santinova-soft.org',
|
||||
desc: 'Monitoring CPU / RAM / Disque',
|
||||
href: 'https://dashboard-santinova.recette.santinova-soft.org',
|
||||
href: 'https://dashboard.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail-santinova.recette.santinova-soft.org',
|
||||
url: 'portail.recette.santinova-soft.org',
|
||||
desc: 'Vitrine centralisée des applications',
|
||||
href: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
href: 'https://portail.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
@@ -80,60 +70,44 @@ const RECETTE = {
|
||||
icon: '🧾',
|
||||
color: 'green',
|
||||
name: 'Démat. Facturation DSI',
|
||||
url: 'demat-facturation-santinova.recette.santinova-soft.org',
|
||||
href: 'https://demat-facturation-santinova.recette.santinova-soft.org',
|
||||
url: 'demat-facturation.recette.santinova-soft.org',
|
||||
href: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '👁️',
|
||||
color: 'indigo',
|
||||
name: 'Veille Réglementaire',
|
||||
url: 'veille-itinova.recette.santinova-soft.org',
|
||||
href: 'https://veille-itinova.recette.santinova-soft.org',
|
||||
url: 'veille.recette.santinova-soft.org',
|
||||
href: 'https://veille.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🗺️',
|
||||
color: 'cyan',
|
||||
name: 'SONUM',
|
||||
url: 'sonum-santinova.recette.santinova-soft.org',
|
||||
url: 'sonum.recette.santinova-soft.org',
|
||||
desc: 'Cartographie solutions numériques FEHAP',
|
||||
href: 'https://sonum-santinova.recette.santinova-soft.org',
|
||||
href: 'https://sonum.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '👤',
|
||||
color: 'pink',
|
||||
name: 'Itinova Contacts',
|
||||
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',
|
||||
url: 'contacts.recette.santinova-soft.org',
|
||||
href: 'https://contacts.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🚗',
|
||||
color: 'yellow',
|
||||
name: 'Flotte Véhicules',
|
||||
url: 'flotte-itinova.recette.santinova-soft.org',
|
||||
href: 'https://flotte-itinova.recette.santinova-soft.org',
|
||||
url: 'flotte.recette.santinova-soft.org',
|
||||
href: 'https://flotte.recette.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: '🎧',
|
||||
color: 'orange',
|
||||
name: 'Podcasts Itinova',
|
||||
url: 'podcasts-itinova.recette.santinova-soft.org',
|
||||
href: 'https://podcasts-itinova.recette.santinova-soft.org',
|
||||
url: 'podcasts.recette.santinova-soft.org',
|
||||
href: 'https://podcasts.recette.santinova-soft.org',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -159,17 +133,17 @@ const PRODUCTION = {
|
||||
icon: LayoutDashboard,
|
||||
color: 'blue',
|
||||
name: 'Dashboard',
|
||||
url: 'dashboard-santinova.santinova-soft.org',
|
||||
url: 'dashboard.santinova-soft.org',
|
||||
desc: 'Monitoring temps réel',
|
||||
href: 'https://dashboard-santinova.santinova-soft.org',
|
||||
href: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
color: 'teal',
|
||||
name: 'Portail Applicatif',
|
||||
url: 'portail-santinova.santinova-soft.org',
|
||||
url: 'portail.santinova-soft.org',
|
||||
desc: 'Vitrine applicative production',
|
||||
href: 'https://portail-santinova.santinova-soft.org',
|
||||
href: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
@@ -274,74 +248,6 @@ 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
|
||||
───────────────────────────────────────────── */
|
||||
@@ -361,7 +267,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 05 Septembre 2026</span>
|
||||
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -394,8 +300,6 @@ 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">
|
||||
|
||||
|
||||
@@ -9,17 +9,8 @@ import {
|
||||
Clock,
|
||||
User,
|
||||
Code,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
||||
import { GITEA_HOST, GITEA_URL } from '../utils/environment';
|
||||
import { getGiteaRepos, getGiteaCommits } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
@@ -36,55 +27,12 @@ function formatSize(kb) {
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!Number.isFinite(seconds)) return 'En attente';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return minutes > 0 ? `${minutes} min ${remainingSeconds.toString().padStart(2, '0')} s` : `${remainingSeconds} s`;
|
||||
}
|
||||
|
||||
function CiStatus({ run }) {
|
||||
if (run.status !== 'completed') return <span className="text-amber-300 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> En cours</span>;
|
||||
if (run.conclusion === 'success') return <span className="text-emerald-300 flex items-center gap-1"><CheckCircle2 className="w-3 h-3" /> Réussi</span>;
|
||||
return <span className="text-red-300 flex items-center gap-1"><XCircle className="w-3 h-3" /> {run.conclusion || 'Échec'}</span>;
|
||||
}
|
||||
|
||||
export default function GiteaPage() {
|
||||
const [repos, setRepos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRepo, setSelectedRepo] = useState(null);
|
||||
const [commits, setCommits] = useState([]);
|
||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
const [rotation, setRotation] = useState(null);
|
||||
const [rotationBusy, setRotationBusy] = useState(false);
|
||||
const [rotationError, setRotationError] = useState('');
|
||||
|
||||
const fetchRotation = async () => {
|
||||
try {
|
||||
const response = await getGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.');
|
||||
}
|
||||
};
|
||||
|
||||
const runRotationAction = async (action) => {
|
||||
setRotationBusy(true);
|
||||
setRotationError('');
|
||||
try {
|
||||
const response = action === 'prepare'
|
||||
? await prepareGiteaTokenRotation()
|
||||
: await approveGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Opération de rotation impossible.');
|
||||
if (error.response?.data?.status) setRotation(error.response.data.status);
|
||||
} finally {
|
||||
setRotationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setLoading(true);
|
||||
@@ -103,19 +51,11 @@ export default function GiteaPage() {
|
||||
setSelectedRepo(fullName);
|
||||
try {
|
||||
const [owner, repo] = fullName.split('/');
|
||||
const [commitsResponse, runsResponse, summaryResponse] = await Promise.all([
|
||||
getGiteaCommits(owner, repo),
|
||||
getGiteaWorkflowRuns(owner, repo),
|
||||
getGiteaWorkflowSummary(owner, repo),
|
||||
]);
|
||||
setCommits(commitsResponse.data);
|
||||
setWorkflowRuns(runsResponse.data);
|
||||
setWorkflowSummary(summaryResponse.data);
|
||||
const res = await getGiteaCommits(owner, repo);
|
||||
setCommits(res.data);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement commits:', err);
|
||||
setCommits([]);
|
||||
setWorkflowRuns([]);
|
||||
setWorkflowSummary(null);
|
||||
} finally {
|
||||
setLoadingCommits(false);
|
||||
}
|
||||
@@ -123,7 +63,6 @@ export default function GiteaPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
fetchRotation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -138,12 +77,12 @@ export default function GiteaPage() {
|
||||
<p className="text-gray-400 mt-1">
|
||||
Explorez les dépôts sur{' '}
|
||||
<a
|
||||
href={GITEA_URL}
|
||||
href="https://git.recette.santinova-soft.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
{GITEA_HOST}
|
||||
git.recette.santinova-soft.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,31 +95,6 @@ export default function GiteaPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 border border-primary-500/20 bg-primary-500/5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary-500/10 p-2"><KeyRound className="h-5 w-5 text-primary-300" /></div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Rotation contrôlée du jeton Gitea</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">{rotation?.message || 'Chargement du statut…'}</p>
|
||||
{rotation?.tokenName && <p className="mt-1 text-xs font-mono text-gray-500">{rotation.tokenName}</p>}
|
||||
{rotation?.updatedAt && <p className="mt-1 text-xs text-gray-500">Mis à jour : {formatDate(rotation.updatedAt)}</p>}
|
||||
{rotationError && <p className="mt-2 text-sm text-red-300">{rotationError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('prepare')} className="btn-secondary flex items-center gap-2 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${rotationBusy ? 'animate-spin' : ''}`} /> Préparer maintenant
|
||||
</button>
|
||||
{rotation?.state === 'pending_approval' && (
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('approve')} className="btn-primary flex items-center gap-2 disabled:opacity-50">
|
||||
<ShieldCheck className="h-4 w-4" /> Approuver le basculement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos List */}
|
||||
<div className="space-y-3">
|
||||
@@ -277,36 +191,6 @@ export default function GiteaPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="card p-3">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">Dernières validations CI</p>
|
||||
{workflowRuns.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Aucune validation CI trouvée pour ce dépôt.</p>
|
||||
) : (
|
||||
<>
|
||||
{workflowSummary?.sampleSize > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3 text-xs">
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className="text-gray-500">Succès</p><p className="font-semibold text-emerald-300">{workflowSummary.successRate}%</p></div>
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className="text-gray-500">Médiane</p><p className="font-semibold text-gray-200">{formatDuration(workflowSummary.medianDurationSeconds)}</p></div>
|
||||
<div className="rounded bg-dark-700/70 p-2"><p className={`font-semibold flex items-center gap-1 ${workflowSummary.trend === 'faster' ? 'text-emerald-300' : workflowSummary.trend === 'slower' ? 'text-red-300' : 'text-gray-300'}`}>{workflowSummary.trend === 'faster' ? <TrendingDown className="w-3 h-3" /> : workflowSummary.trend === 'slower' ? <TrendingUp className="w-3 h-3" /> : <Minus className="w-3 h-3" />}{workflowSummary.changePercent === null ? 'N/A' : `${Math.abs(workflowSummary.changePercent)}%`}</p></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{workflowRuns.slice(0, 5).map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between gap-3 text-xs">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 truncate">{run.name}</p>
|
||||
<p className="text-gray-500">{run.commit?.slice(0, 7) || 'Commit inconnu'} · {formatDate(run.createdAt)}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<CiStatus run={run} />
|
||||
<span className="text-gray-500 flex items-center justify-end gap-1 mt-1"><Clock className="w-3 h-3" />{formatDuration(run.durationSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{commits.map((commit, idx) => (
|
||||
<div key={commit.sha || idx} className="card p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -7,49 +7,49 @@ const APPS_CONFIG = [
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
repoName: 'itinova-contacts',
|
||||
urlRecette: 'https://contacts-itinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://contacts-itinova.santinova-soft.org',
|
||||
urlRecette: 'https://contacts.recette.santinova-soft.org',
|
||||
urlProd: 'https://contacts.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
repoName: 'itinova-podcasts',
|
||||
urlRecette: 'https://podcasts-itinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://podcasts-itinova.santinova-soft.org',
|
||||
urlRecette: 'https://podcasts.recette.santinova-soft.org',
|
||||
urlProd: 'https://podcasts.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'veille-reglementaire',
|
||||
name: 'Veille Réglementaire',
|
||||
repoName: 'veille-reglementaire',
|
||||
urlRecette: 'https://veille-itinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://veille-itinova.santinova-soft.org',
|
||||
urlRecette: 'https://veille.recette.santinova-soft.org',
|
||||
urlProd: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Itinova Gestion de Flotte',
|
||||
repoName: 'itinova-vehicle-exchange',
|
||||
urlRecette: 'https://flotte-itinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://flotte-itinova.santinova-soft.org',
|
||||
urlRecette: 'https://flotte.recette.santinova-soft.org',
|
||||
urlProd: 'https://flotte.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'sonum',
|
||||
name: 'SONUM',
|
||||
repoName: 'sonum',
|
||||
urlRecette: 'https://sonum-santinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://sonum-santinova.santinova-soft.org',
|
||||
urlRecette: 'https://sonum.recette.santinova-soft.org',
|
||||
urlProd: 'https://sonum.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'demat-facturation-dsi',
|
||||
name: 'Démat. Facturation DSI',
|
||||
repoName: 'demat-facturation-dsi',
|
||||
urlRecette: 'https://demat-facturation-santinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://demat-facturation-santinova.santinova-soft.org',
|
||||
urlRecette: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
urlProd: 'https://demat-facturation.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'facturation-santinova',
|
||||
name: 'Facturation Santinova',
|
||||
repoName: 'facturation-santinova',
|
||||
urlRecette: 'https://facturation-santinova.recette.santinova-soft.org',
|
||||
urlRecette: 'https://facturation.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.santinova-soft.org',
|
||||
urlProd: 'https://formations.itinova.org',
|
||||
},
|
||||
{
|
||||
id: 'pilotage-masse-salariale',
|
||||
name: 'Pilotage Masse Salariale',
|
||||
repoName: 'pilotage-masse-salariale',
|
||||
urlRecette: 'https://pilotage-ms-itinova.recette.santinova-soft.org',
|
||||
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
urlProd: null,
|
||||
},
|
||||
{
|
||||
id: 'itinova-budget-si',
|
||||
name: 'Gestion Budget Informatique',
|
||||
repoName: 'itinova-budget-si',
|
||||
urlRecette: 'https://budget-si-santinova.recette.santinova-soft.org',
|
||||
urlRecette: 'https://budget-si.recette.santinova-soft.org',
|
||||
urlProd: null,
|
||||
},
|
||||
{
|
||||
id: 'falc-generator',
|
||||
name: 'FALC Generator',
|
||||
repoName: 'falc-generator',
|
||||
urlRecette: 'https://falc-itinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://falc-itinova.santinova-soft.org',
|
||||
urlRecette: 'https://falc.recette.santinova-soft.org',
|
||||
urlProd: 'https://falc.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'portail-santinova',
|
||||
name: 'Portail Applicatif',
|
||||
repoName: 'portail-santinova',
|
||||
urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://portail-santinova.santinova-soft.org',
|
||||
urlRecette: 'https://portail.recette.santinova-soft.org',
|
||||
urlProd: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard Production',
|
||||
name: 'Dashboard Recette',
|
||||
repoName: 'manus-dashboard',
|
||||
urlRecette: 'https://dashboard-santinova.recette.santinova-soft.org',
|
||||
urlProd: 'https://dashboard-santinova.santinova-soft.org',
|
||||
urlRecette: 'https://dashboard.recette.santinova-soft.org',
|
||||
urlProd: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function LoginPage({ onLogin }) {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
||||
<Server className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard Production</h1>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard Recette</h1>
|
||||
<p className="text-gray-400 mt-2">Gestion des applications</p>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function LoginPage({ onLogin }) {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-sm mt-6">
|
||||
Santinova Soft — Serveur de production
|
||||
Santinova Soft — Serveur de recette
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function TerminalPage() {
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Production Santinova ║\x1b[0m');
|
||||
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Recette Santinova ║\x1b[0m');
|
||||
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
|
||||
term.writeln('');
|
||||
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');
|
||||
|
||||
@@ -54,19 +54,11 @@ export const createApp = (formData) =>
|
||||
// Deployments
|
||||
export const getDeployments = (appId) =>
|
||||
api.get('/deployments', { params: { appId } });
|
||||
export const getCiAlerts = () => api.get('/ci-alerts');
|
||||
|
||||
// Gitea
|
||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||
export const getGiteaCommits = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||
export const getGiteaWorkflowRuns = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
||||
export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation');
|
||||
export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare');
|
||||
export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve');
|
||||
|
||||
// Docker
|
||||
export const getContainers = () => api.get('/docker/containers');
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Retourne une copie triée sans modifier la liste fournie par l'API.
|
||||
* Les services INFRA sont affichés en premier ; l'ordre d'origine est conservé
|
||||
* au sein de chaque groupe afin d'éviter des déplacements visuels inutiles.
|
||||
*/
|
||||
export function sortAppsWithInfraFirst(apps) {
|
||||
return (Array.isArray(apps) ? apps : [])
|
||||
.map((app, index) => ({ app, index }))
|
||||
.sort((left, right) => {
|
||||
const leftPriority = left.app?.category === 'INFRA' ? 0 : 1;
|
||||
const rightPriority = right.app?.category === 'INFRA' ? 0 : 1;
|
||||
return leftPriority - rightPriority || left.index - right.index;
|
||||
})
|
||||
.map(({ app }) => app);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,28 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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