Compare commits

..

14 Commits

42 changed files with 6771 additions and 462 deletions

View File

@@ -0,0 +1,27 @@
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 run build

45
APP_MANIFEST.md Normal file
View File

@@ -0,0 +1,45 @@
# 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 nest pas considérée comme déployée. Elle napparaî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",
"urls": {
"recette": "https://contacts.recette.santinova-soft.org",
"prod": "https://contacts.santinova-soft.org"
}
}
```
| Champ | Rôle | Règle |
|---|---|---|
| `id` | Identifiant stable de lapplication | 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 à linventaire. |
| `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. |
| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. |
| `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 à lapplication. Lorsquune application est promue, son manifeste de production doit contenir lURL de production avant le démarrage du dashboard.
Le dashboard ne maintient plus de liste statique dapplications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, dURL, de catégorie et de statut entre les deux interfaces.

13
app.json Normal file
View File

@@ -0,0 +1,13 @@
{
"id": "manus-dashboard",
"name": "Dashboard Production",
"category": "INFRA",
"urls": {"recette": "https://dashboard.recette.santinova-soft.org", "prod": "https://dashboard.santinova-soft.org"},
"containerName": "manus-dashboard",
"image": "images/dashboard.png",
"giteaRepo": "manus-dashboard",
"giteaOwner": "manus-admin",
"ci": {
"required": true
}
}

View File

@@ -4,23 +4,30 @@ services:
context: ./src context: ./src
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: manus-dashboard container_name: manus-dashboard
restart: unless-stopped # Redémarrage automatique après un arrêt anormal ; le processus reste borné.
restart: always
mem_limit: 512m
privileged: true privileged: true
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=${NODE_ENV:-production}
- PORT=3001 - DASHBOARD_ENV=prod
- JWT_SECRET=${DASHBOARD_JWT_SECRET} - PORT=${PORT:-3001}
- ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} - JWT_SECRET=${JWT_SECRET}
- ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GITEA_URL=${GITEA_URL} - GITEA_URL=${GITEA_URL}
- GITEA_USERNAME=${GITEA_USERNAME} - GITEA_USERNAME=${GITEA_USERNAME}
- GITEA_PASSWORD=${GITEA_PASSWORD} - GITEA_PASSWORD=${GITEA_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET} - GITEA_TOKEN=${GITEA_TOKEN}
- APPS_BASE_PATH=${APPS_BASE_PATH} - GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
- INFRA_BASE_PATH=${INFRA_BASE_PATH} - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL} - 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}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy - /opt/manus-deploy:/opt/manus-deploy
@@ -36,6 +43,7 @@ services:
- "traefik.http.routers.manus-dashboard.priority=100" - "traefik.http.routers.manus-dashboard.priority=100"
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=web" - "traefik.docker.network=web"
networks: networks:
web: web:
external: true external: true

View File

@@ -0,0 +1,9 @@
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
FROM node:22-alpine
# actions/cache sappuie sur GNU tar pour archiver le store pnpm.
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

17
ops/README.md Normal file
View File

@@ -0,0 +1,17 @@
# 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é dune route, y compris une API sans page racine ou un tableau protégé. En cas danomalie, 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`.
Les pushes Gitea du dépôt `manus-dashboard` ne recréent jamais le conteneur depuis lui-même. Le webhook dépose une demande atomique, surveillée par `manus-dashboard-self-deploy.path`, puis le service hôte exécute le redéploiement. Cette séparation évite larrêt du client Docker avant le démarrage du conteneur de remplacement.
Après mise à jour Git, installer ou actualiser le service avec :
```bash
sudo ./ops/install-healthcheck.sh
```
## Runner CI de production
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise limage locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Déploie le dashboard depuis lhôte systemd, jamais depuis le conteneur lui-même.
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"

View File

@@ -0,0 +1,17 @@
log:
level: info
runner:
capacity: 1
envs:
DOCKER_HOST: unix:///var/run/docker.sock
# Cache Gitea Actions persistant, accessible depuis les jobs du réseau Docker web.
cache:
enabled: true
dir: /opt/manus-deploy/gitea-runner/cache
host: 172.18.0.1
port: 18088
container:
network: web

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Runner Gitea Actions de production
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

120
ops/healthcheck-apps.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/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 dune 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"

38
ops/install-gitea-act-runner.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
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.santinova-soft.org"
readonly RUNNER_NAME="production-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}" "${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}"
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

19
ops/install-healthcheck.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/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 linstallation du timer.
systemctl start manus-apps-health.service || true
systemctl status manus-apps-health.service --no-pager || true

View File

@@ -0,0 +1,13 @@
[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

View File

@@ -0,0 +1,12 @@
[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

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Surveille les demandes de redéploiement du Manus Dashboard
[Path]
PathExists=/opt/manus-deploy/apps/manus-dashboard/.deployment-request
Unit=manus-dashboard-self-deploy.service
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redéploiement hôte du Manus Dashboard
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

View File

@@ -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" && \ RUN git config --global user.email "admin@santinova-soft.org" && \
git config --global user.name "Manus Dashboard" git config --global user.name "Manus Dashboard"
# Copy backend # Installer les dépendances backend depuis un verrou versionné pour un build déterministe.
COPY backend/package.json ./backend/ COPY backend/package.json backend/package-lock.json ./backend/
WORKDIR /app/backend WORKDIR /app/backend
RUN npm install --production RUN npm ci --omit=dev
COPY backend/ ./ COPY backend/ ./

2483
src/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon src/index.js" "dev": "nodemon src/index.js",
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",

View File

@@ -4,6 +4,7 @@ const path = require('path');
const { exec, execSync } = require('child_process'); const { exec, execSync } = require('child_process');
const config = require('./config'); const config = require('./config');
const { createRepo, giteaClient } = require('./gitea'); const { createRepo, giteaClient } = require('./gitea');
const { refreshApps, writeAppManifest } = require('./app-registry');
// ============ TEMPLATES DOCKERFILE ============ // ============ TEMPLATES DOCKERFILE ============
@@ -389,59 +390,29 @@ function getContainerPort(stack, customPort) {
return defaults[stack] || 3000; return defaults[stack] || 3000;
} }
// ============ APPS PERSISTENCE ============ // ============ REGISTRE DES APPLICATIONS ============
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);
}
}
function addDynamicApp(appDef) {
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);
}
}
/**
* 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() { function initDynamicApps() {
const dynamicApps = loadDynamicApps(); return refreshApps(config);
for (const app of dynamicApps) { }
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
if (existingInConfig < 0) { /**
config.apps.push(app); * 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.
} */
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`); function addDynamicApp(appDef) {
const app = writeAppManifest({
appsBasePath: config.appsBasePath,
environment: config.environment,
app: appDef,
});
refreshApps(config);
return app;
} }
// ============ CREATION D'APPLICATION ============ // ============ CREATION D'APPLICATION ============
@@ -455,6 +426,7 @@ async function createApplication(params, logCallback) {
port, port,
needsDb, needsDb,
dbType, dbType,
category,
sourceType, // 'zip' or 'gitea' sourceType, // 'zip' or 'gitea'
giteaRepoUrl, // URL du repo Gitea existant giteaRepoUrl, // URL du repo Gitea existant
zipFilePath, // Chemin du fichier ZIP uploadé zipFilePath, // Chemin du fichier ZIP uploadé
@@ -652,6 +624,8 @@ async function createApplication(params, logCallback) {
stack: stack, stack: stack,
needsDb: needsDb || false, needsDb: needsDb || false,
dbType: dbType || null, dbType: dbType || null,
category: category || 'SANTINOVA',
image: `images/${appId}.png`,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -704,7 +678,6 @@ module.exports = {
generateDockerCompose, generateDockerCompose,
getAvailableStacks, getAvailableStacks,
initDynamicApps, initDynamicApps,
loadDynamicApps,
addDynamicApp, addDynamicApp,
DOCKERFILE_TEMPLATES, DOCKERFILE_TEMPLATES,
}; };

View File

@@ -0,0 +1,160 @@
/*
* 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 CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
const ENVIRONMENTS = new Set(['recette', 'prod']);
function isHttpsUrl(value) {
if (typeof value !== 'string' || value.length === 0) return false;
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
/**
* 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');
}
return {
...manifest,
name: manifest.name.trim(),
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) {
const result = discoverApps({
appsBasePath: config.appsBasePath,
environment: config.environment,
});
config.apps.splice(0, config.apps.length, ...result.apps);
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;
}
/**
* É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;
}
/**
* Retourne le manifeste complet afin que le webhook applique les règles de
* promotion déclarées par l'application (notamment ci.required).
*/
function findAppByRepo(config, repositoryName) {
const { apps } = refreshApps(config);
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
}
module.exports = {
discoverApps,
refreshApps,
writeAppManifest,
findAppDirectoryByRepo,
findAppByRepo,
};

View File

@@ -1,246 +1,38 @@
/*
* 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 = { module.exports = {
port: process.env.PORT || 3001, port: Number.parseInt(process.env.PORT, 10) || 3001,
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026', jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
jwtExpiry: '24h', jwtExpiry: '24h',
// Authentification environment,
auth: { auth: {
username: process.env.ADMIN_USERNAME || 'adminItinova', username: process.env.ADMIN_USERNAME || 'adminItinova',
password: process.env.ADMIN_PASSWORD || 'Itinova69!', password: process.env.ADMIN_PASSWORD || 'Itinova69!',
}, },
// Gitea
gitea: { gitea: {
url: process.env.GITEA_URL || 'http://gitea:3000', url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin', username: process.env.GITEA_USERNAME || 'manus-admin',
password: process.env.GITEA_PASSWORD || 'Itinova69!', password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null, token: process.env.GITEA_TOKEN || null,
}, },
// Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure', infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms) healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration // Cache mémoire hydraté au démarrage puis après chaque création d'application.
apps: [ 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: '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',
},
{
id: 'falc-generator',
name: 'FALC Generator',
description: 'Générateur de documents FALC (Facile À Lire et à Comprendre)',
directory: 'falc-generator',
giteaRepo: 'falc-generator',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://falc.recette.santinova-soft.org',
prod: 'https://falc.santinova-soft.org',
},
containerName: 'falc-generator',
healthCheckUrl: 'https://falc.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
],
}; };

View File

@@ -139,11 +139,38 @@ function gitPull(appConfig) {
/** /**
* Démarrer un conteneur Docker * Démarrer un conteneur Docker
*/ */
async function startContainer(containerName) { async function startContainer(containerName, appId) {
try { try {
// Tenter d'abord un docker start (conteneur existant mais arrêté)
const container = docker.getContainer(containerName); const container = docker.getContainer(containerName);
await container.start(); const info = await container.inspect().catch(() => null);
return { success: true, message: `Conteneur ${containerName} démarré` }; 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` };
} catch (err) { } catch (err) {
return { success: false, message: err.message }; return { success: false, message: err.message };
} }

View File

@@ -70,6 +70,97 @@ 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;
}
/** Normalise les exécutions CI pour le webhook et le dashboard. */
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);
}
/**
* Construit une synthèse déterministe sur les derniers runs CI.
* Les comparaisons ne sont calculées quavec deux fenêtres dau moins deux runs
* pour ne pas transformer une variation isolée en tendance.
*/
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));
}
/** /**
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS) * Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
*/ */
@@ -113,6 +204,11 @@ module.exports = {
getRepo, getRepo,
getCommits, getCommits,
getBranches, getBranches,
getWorkflowRuns,
getWorkflowRunSummary,
getWorkflowRunForCommit,
normalizeWorkflowRun,
calculateCiSummary,
listRepos, listRepos,
createRepo, createRepo,
giteaClient, giteaClient,

View File

@@ -3,125 +3,130 @@ const https = require('https');
const config = require('./config'); const config = require('./config');
const { getContainerInfo } = require('./docker'); const { getContainerInfo } = require('./docker');
// Store pour les statuts des apps // État volatile : il est régénéré au démarrage par les contrôles de santé.
const appStatuses = new Map(); const appStatuses = new Map();
// Store pour les logs de déploiement
const deploymentLogs = []; const deploymentLogs = [];
const MAX_DEPLOYMENT_LOGS = 50;
// Store pour les commits Gitea
const giteaCommits = new Map(); const giteaCommits = new Map();
const MAX_DEPLOYMENT_LOGS = 50;
const MAX_CONCURRENT_CHECKS = 4;
// Agent HTTPS qui ignore les certificats auto-signés // 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.
const httpsAgent = new https.Agent({ rejectUnauthorized: false }); 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() { function cleanupOrphanedDeployments() {
const orphans = deploymentLogs.filter((l) => l.status === 'running'); 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();
}
if (orphans.length > 0) { if (orphans.length > 0) {
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`); console.log(`[healthcheck] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
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) { async function checkUrl(url) {
try { try {
const start = Date.now(); const startedAt = Date.now();
const response = await axios.get(url, { const response = await axios.get(url, {
timeout: 10000, timeout: 8000,
httpsAgent, httpsAgent,
validateStatus: (status) => status < 500, validateStatus: (status) => status < 500,
}); });
const responseTime = Date.now() - start;
return { return {
online: true, online: true,
statusCode: response.status, statusCode: response.status,
responseTime, responseTime: Date.now() - startedAt,
}; };
} catch (err) { } catch (error) {
return { return {
online: false, online: false,
statusCode: null, statusCode: null,
responseTime: null, responseTime: null,
error: err.message, error: error.message,
}; };
} }
} }
/** /**
* Vérifier la santé d'une application * 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.
*/ */
async function checkApp(appConfig) { async function checkApp(appConfig) {
const environment = config.environment;
const localUrl = appConfig.urls?.[environment];
const result = { const result = {
id: appConfig.id, id: appConfig.id,
name: appConfig.name, name: appConfig.name,
description: appConfig.description, description: appConfig.description || '',
urls: appConfig.urls, urls: appConfig.urls,
containerName: appConfig.containerName, containerName: appConfig.containerName,
lastCheck: new Date().toISOString(), lastCheck: new Date().toISOString(),
status: 'unknown', status: 'unknown',
container: null, container: null,
health: { health: { recette: null, prod: null },
recette: null,
prod: null,
},
}; };
// Vérifier le conteneur Docker const [containerInfo, health] = await Promise.all([
const containerInfo = await getContainerInfo(appConfig.containerName); getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
]);
result.container = containerInfo; 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) { if (!containerInfo || !containerInfo.running) {
result.status = 'offline'; result.status = 'offline';
} else if (result.health.recette && result.health.recette.online) { } else if (health?.online) {
result.status = 'online'; result.status = 'online';
} else if (containerInfo.running) {
result.status = 'error';
} else { } else {
result.status = 'offline'; result.status = 'error';
} }
// Stocker le résultat
appStatuses.set(appConfig.id, result); appStatuses.set(appConfig.id, result);
return result; return result;
} }
/** /** Exécute un lot asynchrone avec une concurrence bornée. */
* Lancer les health checks pour toutes les apps async function mapWithConcurrency(items, limit, worker) {
*/ const results = new Array(items.length);
async function checkAllApps() { let cursor = 0;
const results = [];
for (const app of config.apps) { async function runWorker() {
const result = await checkApp(app); while (cursor < items.length) {
results.push(result); const index = cursor++;
results[index] = await worker(items[index]);
}
} }
const workerCount = Math.min(Math.max(limit, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, runWorker));
return results; return results;
} }
/** /**
* Ajouter un log de déploiement (retourne l'entrée créée avec son id) * 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.
*/ */
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) { function addDeploymentLog(appId, log) {
const entry = { const entry = {
id: Date.now().toString(), id: Date.now().toString(),
@@ -130,45 +135,25 @@ function addDeploymentLog(appId, log) {
...log, ...log,
}; };
deploymentLogs.unshift(entry); deploymentLogs.unshift(entry);
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) { if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
deploymentLogs.pop();
}
return entry; 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) { function updateDeploymentLog(entryId, updates) {
const entry = deploymentLogs.find((l) => l.id === entryId); const entry = deploymentLogs.find((log) => log.id === entryId);
if (entry) { if (!entry) return null;
Object.assign(entry, updates, { updatedAt: new Date().toISOString() }); Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry; return entry;
}
return null;
} }
/**
* Récupérer les logs de déploiement
*/
function getDeploymentLogs(appId = null) { function getDeploymentLogs(appId = null) {
if (appId) { return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
return deploymentLogs.filter((l) => l.appId === appId);
}
return deploymentLogs;
} }
/**
* Récupérer le statut d'une app
*/
function getAppStatus(appId) { function getAppStatus(appId) {
return appStatuses.get(appId) || null; return appStatuses.get(appId) || null;
} }
/**
* Récupérer tous les statuts
*/
function getAllStatuses() { function getAllStatuses() {
return Array.from(appStatuses.values()); return Array.from(appStatuses.values());
} }
@@ -177,6 +162,7 @@ module.exports = {
checkUrl, checkUrl,
checkApp, checkApp,
checkAllApps, checkAllApps,
mapWithConcurrency,
addDeploymentLog, addDeploymentLog,
updateDeploymentLog, updateDeploymentLog,
getDeploymentLogs, getDeploymentLogs,

View File

@@ -33,10 +33,6 @@ app.use(cors({
origin: true, origin: true,
credentials: 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 // 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) => { app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
@@ -46,6 +42,11 @@ app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' })
} }
next(); next();
}, webhookRoutes); }, webhookRoutes);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// API routes // API routes
app.use('/api', routes); app.use('/api', routes);

View File

@@ -4,7 +4,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { authenticate, authMiddleware } = require('./auth'); const { authenticate, authMiddleware } = require('./auth');
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker'); const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea'); const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck'); const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const config = require('./config'); const config = require('./config');
@@ -256,7 +256,7 @@ router.get('/apps-config/stacks', authMiddleware, (req, res) => {
// Créer une nouvelle application // Créer une nouvelle application
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => { router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
try { try {
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body; const { name, description, subdomain, stack, port, needsDb, dbType, category, sourceType, giteaRepoUrl } = req.body;
// Validation // Validation
if (!name || !subdomain || !stack || !sourceType) { if (!name || !subdomain || !stack || !sourceType) {
@@ -322,6 +322,7 @@ router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), asy
port: port ? parseInt(port) : null, port: port ? parseInt(port) : null,
needsDb: needsDb === 'true' || needsDb === true, needsDb: needsDb === 'true' || needsDb === true,
dbType: dbType || 'mysql', dbType: dbType || 'mysql',
category: category || 'SANTINOVA',
sourceType, sourceType,
giteaRepoUrl, giteaRepoUrl,
zipFilePath: req.file ? req.file.path : null, zipFilePath: req.file ? req.file.path : null,
@@ -398,6 +399,25 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
} }
}); });
// Dernières validations CI avec statut et durée, utilisées par l'écran Gitea.
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 });
}
});
// Synthèse historique : taux de succès, médiane et tendance des validations CI.
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) ============ // ============ PUBLIC STATUS ROUTE (sans authentification) ============
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées // Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
router.get('/public/status', async (req, res) => { router.get('/public/status', async (req, res) => {
@@ -417,6 +437,36 @@ router.get('/public/status', async (req, res) => {
} }
}); });
// ============ PUBLIC APPS ROUTE (sans authentification) ============
// Utilisé par le portail applicatif pour construire les tuiles dynamiquement
// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles
router.get('/public/apps', (req, res) => {
try {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// 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();
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 {
id: a.id,
name: a.name,
category: a.category || 'SANTINOVA',
image: a.image || ('images/' + a.id + '.png'),
urls: a.urls || {},
status: status ? status.status : 'unknown',
containerRunning: status && status.container ? status.container.running : false,
};
});
res.json(publicApps);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ DOCKER ROUTES ============ // ============ DOCKER ROUTES ============
router.get('/docker/containers', authMiddleware, async (req, res) => { router.get('/docker/containers', authMiddleware, async (req, res) => {
@@ -452,7 +502,7 @@ router.post('/apps/:id/start', authMiddleware, async (req, res) => {
const app = config.apps.find((a) => a.id === req.params.id); const app = config.apps.find((a) => a.id === req.params.id);
if (!app) return res.status(404).json({ error: 'Application non trouvée' }); if (!app) return res.status(404).json({ error: 'Application non trouvée' });
const containerName = app.containerName || app.id; const containerName = app.containerName || app.id;
const result = await startContainer(containerName); const result = await startContainer(containerName, app.id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -505,21 +555,14 @@ const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!'; 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_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
const INVENTORY_APPS = [ // INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
{ id: 'itinova-contacts', name: 'Itinova Contacts', repoName: 'itinova-contacts' }, function getInventoryApps() {
{ id: 'itinova-podcasts', name: 'Itinova Podcasts', repoName: 'itinova-podcasts' }, return config.apps.map((a) => ({
{ id: 'veille-reglementaire', name: 'Veille Réglementaire', repoName: 'veille-reglementaire' }, id: a.id,
{ id: 'itinova-vehicle-exchange', name: 'Itinova Gestion de Flotte', repoName: 'itinova-vehicle-exchange' }, name: a.name,
{ id: 'sonum', name: 'SONUM', repoName: 'sonum' }, repoName: a.giteaRepo || a.id,
{ id: 'demat-facturation-dsi', name: 'Démat. Facturation DSI', repoName: 'demat-facturation-dsi' }, }));
{ id: 'facturation-santinova', name: 'Facturation Santinova', repoName: 'facturation-santinova' }, }
{ id: 'formation-manager-itinova', name: 'Formation Manager', repoName: 'formation-manager-itinova' },
{ id: 'pilotage-masse-salariale', name: 'Pilotage Masse Salariale', repoName: 'pilotage-masse-salariale' },
{ id: 'itinova-budget-si', name: 'Gestion Budget Informatique', repoName: 'itinova-budget-si' },
{ id: 'falc-generator', name: 'FALC Generator', repoName: 'falc-generator' },
{ id: 'portail-santinova', name: 'Portail Applicatif', repoName: 'portail-santinova' },
{ id: 'manus-dashboard', name: 'Dashboard Manus', repoName: 'manus-dashboard' },
];
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) { function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -559,6 +602,7 @@ function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
router.get('/inventory', authMiddleware, async (req, res) => { router.get('/inventory', authMiddleware, async (req, res) => {
try { try {
const owner = GITEA_USER_INV; const owner = GITEA_USER_INV;
const INVENTORY_APPS = getInventoryApps();
const results = await Promise.all( const results = await Promise.all(
INVENTORY_APPS.map(async (app) => { INVENTORY_APPS.map(async (app) => {
const [repoRecette, repoProd] = await Promise.all([ const [repoRecette, repoProd] = await Promise.all([

View File

@@ -5,9 +5,12 @@
*/ */
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const { exec } = require('child_process'); const { execFile } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const config = require('./config');
const { findAppByRepo } = require('./app-registry');
const { getWorkflowRunForCommit } = require('./gitea');
const router = express.Router(); const router = express.Router();
@@ -15,29 +18,89 @@ const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps'; const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh'; const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy'; const LOG_DIR = '/var/log/manus-deploy';
const DASHBOARD_APP_ID = 'manus-dashboard';
// Mapping dépôt Gitea -> nom du dossier application sur le serveur const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
const REPO_TO_APP_MAP = { const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
'itinova-contacts': 'itinova-contacts', const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
'itinova-podcasts': 'itinova-podcasts',
'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard',
'itinova-budget-si': 'itinova-budget-si',
};
// Déploiements en cours (évite les doubles déclenchements) // Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set(); const deployingApps = new Set();
/**
* Le conteneur ne peut pas se recréer lui-même : Docker interrompt son client
* `docker compose` au moment où le conteneur courant est arrêté. Ce cas est
* donc délégué au service systemd hôte manus-dashboard-self-deploy.path.
*/
function isHostManagedDeployment(appName) {
return appName === DASHBOARD_APP_ID;
}
function requestDashboardHostDeployment({ branch, commitHash, committer }) {
const temporaryRequest = `${DASHBOARD_DEPLOY_REQUEST}.${process.pid}.tmp`;
const request = {
branch,
commitHash,
committer,
requestedAt: new Date().toISOString(),
};
fs.writeFileSync(temporaryRequest, JSON.stringify(request, 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));
}
/**
* Une application déclarant ci.required ne peut être déployée que si le commit
* reçu dispose dun run Gitea terminé avec succès.
*/
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 na pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
};
}
function verifyGiteaSignature(req) { function verifyGiteaSignature(req) {
if (!WEBHOOK_SECRET) { if (!WEBHOOK_SECRET) {
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.'); console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
return true; return true;
} }
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || ''; 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 // 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 bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex'); const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
@@ -63,10 +126,14 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
} }
const env = { ...process.env, APPS_BASE_PATH }; const env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = ''; let output = '';
const child = exec(cmd, { env, timeout: 600000 }); // Les deux serveurs partagent le module, mais leurs scripts de déploiement
// nattendent 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 });
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); }); 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}`); }); child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
@@ -77,16 +144,41 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
if (broadcast) { if (broadcast) {
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } }); broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
} }
try { saveDeploymentStatus(appName, {
fs.mkdirSync(LOG_DIR, { recursive: true }); app: appName, branch, commit: commitHash, committer, status, exitCode: code,
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({ timestamp: new Date().toISOString(), output: output.slice(-3000),
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,
};
console.warn(`[Webhook] Déploiement bloqué pour ${app.directory}: ${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 // POST /api/webhook/gitea
router.post('/gitea', (req, res) => { router.post('/gitea', (req, res) => {
if (!verifyGiteaSignature(req)) { if (!verifyGiteaSignature(req)) {
@@ -105,15 +197,21 @@ router.post('/gitea', (req, res) => {
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`); console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName]; // Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` }); const app = findAppByRepo(config, repoName);
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` }); if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer }); 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),
});
// Récupérer la fonction broadcast si disponible globalement // Récupérer la fonction broadcast si disponible globalement
const broadcastFn = global.wsBroadcast || null; const broadcastFn = global.wsBroadcast || null;
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn)); setImmediate(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
}); });
// GET /api/webhook/status/:appName // GET /api/webhook/status/:appName
@@ -141,3 +239,6 @@ router.get('/status', (req, res) => {
}); });
module.exports = router; module.exports = router;
module.exports.isHostManagedDeployment = isHostManagedDeployment;
module.exports.shouldRequireCi = shouldRequireCi;
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;

View File

@@ -0,0 +1,61 @@
/*
* 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 } = 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',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
},
});
writeManifest(sandbox, 'invalide', {
id: 'app-invalide',
name: 'Application invalide',
category: 'INCONNUE',
urls: { recette: 'https://invalide.recette.santinova-soft.org' },
});
const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' });
assert.equal(discovery.apps.length, 1, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée');
const app = discovery.apps[0];
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 lenvironnement courant');
assert.equal(app.containerName, 'itinova-contacts');
const config = {
appsBasePath: sandbox,
environment: 'recette',
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
};
const refresh = refreshApps(config);
assert.equal(refresh.apps.length, 1, 'les applications non déployées ne doivent pas rester en cache');
assert.equal(config.apps[0].name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique');
console.log('OK app-registry');
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@@ -0,0 +1,27 @@
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');

View File

@@ -0,0 +1,25 @@
/*
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
* temps, même lorsquun grand nombre dapplications 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);
});

View File

@@ -0,0 +1,14 @@
/*
* Un conteneur ne peut pas recréer fiablement son propre conteneur Docker :
* le client `docker compose` est arrêté en même temps que lui. Le webhook doit
* donc déléguer ce cas précis à un service systemd hôte.
*/
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');

View File

@@ -1,2 +0,0 @@
VITE_ENV_NAME=Production
VITE_ENV_LABEL=Serveur de production

View File

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

3030
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ export default function Sidebar({
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-white">Dashboard</h1> <h1 className="text-lg font-bold text-white">Dashboard</h1>
<p className="text-xs text-gray-500">Recette</p> <p className="text-xs text-gray-500">Production</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -147,10 +147,12 @@ export default function DashboardPage({ apps }) {
<p className="text-gray-500 text-sm">Aucune application détectée</p> <p className="text-gray-500 text-sm">Aucune application détectée</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{apps.map((app) => ( {apps.map((app) => {
const isInfra = app.category === "INFRA" || ["portail-santinova", "manus-dashboard"].includes(app.id);
return (
<div <div
key={app.id} key={app.id}
className="flex items-center justify-between py-3 px-4 rounded-lg bg-dark-700/50 border border-dark-600/50" 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"}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
@@ -163,7 +165,12 @@ export default function DashboardPage({ apps }) {
}`} }`}
/> />
<div> <div>
<p className="text-sm font-medium text-gray-200"> {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"}`}>
{app.name} {app.name}
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@@ -180,7 +187,8 @@ export default function DashboardPage({ apps }) {
<StatusBadge status={app.status} /> <StatusBadge status={app.status} />
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -9,8 +9,14 @@ import {
Clock, Clock,
User, User,
Code, Code,
CheckCircle2,
XCircle,
Loader2,
TrendingDown,
TrendingUp,
Minus,
} from 'lucide-react'; } from 'lucide-react';
import { getGiteaRepos, getGiteaCommits } from '../utils/api'; import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return 'N/A'; if (!dateStr) return 'N/A';
@@ -27,11 +33,30 @@ function formatSize(kb) {
return `${(kb / 1024).toFixed(1)} MB`; 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() { export default function GiteaPage() {
const [repos, setRepos] = useState([]); const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedRepo, setSelectedRepo] = useState(null); const [selectedRepo, setSelectedRepo] = useState(null);
const [commits, setCommits] = useState([]); const [commits, setCommits] = useState([]);
const [workflowRuns, setWorkflowRuns] = useState([]);
const [workflowSummary, setWorkflowSummary] = useState(null);
const [loadingCommits, setLoadingCommits] = useState(false); const [loadingCommits, setLoadingCommits] = useState(false);
const fetchRepos = async () => { const fetchRepos = async () => {
@@ -51,11 +76,19 @@ export default function GiteaPage() {
setSelectedRepo(fullName); setSelectedRepo(fullName);
try { try {
const [owner, repo] = fullName.split('/'); const [owner, repo] = fullName.split('/');
const res = await getGiteaCommits(owner, repo); const [commitsResponse, runsResponse, summaryResponse] = await Promise.all([
setCommits(res.data); getGiteaCommits(owner, repo),
getGiteaWorkflowRuns(owner, repo),
getGiteaWorkflowSummary(owner, repo),
]);
setCommits(commitsResponse.data);
setWorkflowRuns(runsResponse.data);
setWorkflowSummary(summaryResponse.data);
} catch (err) { } catch (err) {
console.error('Erreur chargement commits:', err); console.error('Erreur chargement commits:', err);
setCommits([]); setCommits([]);
setWorkflowRuns([]);
setWorkflowSummary(null);
} finally { } finally {
setLoadingCommits(false); setLoadingCommits(false);
} }
@@ -191,6 +224,48 @@ export default function GiteaPage() {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <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="text-gray-500">Tendance</p>
<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) => ( {commits.map((commit, idx) => (
<div key={commit.sha || idx} className="card p-3"> <div key={commit.sha || idx} className="card p-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@@ -89,7 +89,7 @@ const APPS_CONFIG = [
}, },
{ {
id: 'manus-dashboard', id: 'manus-dashboard',
name: 'Dashboard Recette', name: 'Dashboard',
repoName: 'manus-dashboard', repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.santinova-soft.org', urlRecette: 'https://dashboard.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org', urlProd: 'https://dashboard.santinova-soft.org',
@@ -107,9 +107,9 @@ function SyncBadge({ versionRecette, versionProd }) {
if (!shaRec && !shaProd) { if (!shaRec && !shaProd) {
return ( 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" /> <Minus className="w-3 h-3" />
N/A Non déployé
</span> </span>
); );
} }
@@ -123,9 +123,9 @@ function SyncBadge({ versionRecette, versionProd }) {
} }
if (!shaProd) { if (!shaProd) {
return ( 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" /> <ArrowUp className="w-3 h-3" />
Recette en avance Non déployé en prod
</span> </span>
); );
} }
@@ -175,19 +175,19 @@ function SyncBadge({ versionRecette, versionProd }) {
} }
function VersionBadge({ version, loading }) { 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 (!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 ( 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" /> <AlertCircle className="w-3 h-3" />
N/A Non déployé
</span> </span>
); );
} }

View File

@@ -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"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
<Server className="w-8 h-8 text-white" /> <Server className="w-8 h-8 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Dashboard {import.meta.env.VITE_ENV_NAME || "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> <p className="text-gray-400 mt-2">Gestion des applications</p>
</div> </div>
@@ -130,7 +132,7 @@ export default function LoginPage({ onLogin }) {
</div> </div>
<p className="text-center text-gray-600 text-sm mt-6"> <p className="text-center text-gray-600 text-sm mt-6">
Santinova Soft &mdash; {import.meta.env.VITE_ENV_LABEL || "Serveur de recette"} Santinova Soft &mdash; Serveur de production
</p> </p>
</div> </div>
</div> </div>

View File

@@ -116,7 +116,7 @@ export default function TerminalPage() {
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m'); term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Santinova ║\x1b[0m'); term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Production Santinova ║\x1b[0m');
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m'); term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
term.writeln(''); term.writeln('');
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m'); term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');

View File

@@ -59,6 +59,10 @@ export const getDeployments = (appId) =>
export const getGiteaRepos = () => api.get('/gitea/repos'); export const getGiteaRepos = () => api.get('/gitea/repos');
export const getGiteaCommits = (owner, repo) => export const getGiteaCommits = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/commits`); 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`);
// Docker // Docker
export const getContainers = () => api.get('/docker/containers'); export const getContainers = () => api.get('/docker/containers');