Compare commits
15 Commits
907f5c0678
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79240dd914 | ||
|
|
62169d655a | ||
|
|
b436176446 | ||
|
|
a455d3dd13 | ||
|
|
d9610d2be4 | ||
|
|
c3bf677972 | ||
|
|
40ac705398 | ||
|
|
255f54cf27 | ||
|
|
fe0a81fc98 | ||
|
|
abba8e352a | ||
|
|
e07e569559 | ||
|
|
b4647580fb | ||
|
|
0b59245f76 | ||
|
|
9b795612e4 | ||
|
|
6bfad4df8a |
27
.gitea/workflows/validate.yml
Normal file
27
.gitea/workflows/validate.yml
Normal 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
45
APP_MANIFEST.md
Normal 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 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",
|
||||||
|
"urls": {
|
||||||
|
"recette": "https://contacts.recette.santinova-soft.org",
|
||||||
|
"prod": "https://contacts.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/`. |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
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.
|
||||||
13
app.json
Normal file
13
app.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
9
ops/Dockerfile.gitea-runner
Normal file
9
ops/Dockerfile.gitea-runner
Normal 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 s’appuie 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
17
ops/README.md
Normal 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é 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`.
|
||||||
|
|
||||||
|
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 l’arrê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 l’image 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`.
|
||||||
24
ops/deploy-dashboard-from-host.sh
Executable file
24
ops/deploy-dashboard-from-host.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Déploie le dashboard depuis l’hô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"
|
||||||
17
ops/gitea-act-runner-config.yaml
Normal file
17
ops/gitea-act-runner-config.yaml
Normal 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
|
||||||
17
ops/gitea-act-runner.service
Normal file
17
ops/gitea-act-runner.service
Normal 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
120
ops/healthcheck-apps.sh
Executable 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 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"
|
||||||
38
ops/install-gitea-act-runner.sh
Executable file
38
ops/install-gitea-act-runner.sh
Executable 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
19
ops/install-healthcheck.sh
Executable 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 l’installation du timer.
|
||||||
|
systemctl start manus-apps-health.service || true
|
||||||
|
systemctl status manus-apps-health.service --no-pager || true
|
||||||
13
ops/manus-apps-health.service
Normal file
13
ops/manus-apps-health.service
Normal 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
|
||||||
12
ops/manus-apps-health.timer
Normal file
12
ops/manus-apps-health.timer
Normal 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
|
||||||
9
ops/manus-dashboard-self-deploy.path
Normal file
9
ops/manus-dashboard-self-deploy.path
Normal 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
|
||||||
13
ops/manus-dashboard-self-deploy.service
Normal file
13
ops/manus-dashboard-self-deploy.service
Normal 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
|
||||||
@@ -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
2483
src/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
160
src/backend/src/app-registry.js
Normal file
160
src/backend/src/app-registry.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
const info = await container.inspect().catch(() => null);
|
||||||
|
if (info) {
|
||||||
|
// Le conteneur existe, on le démarre
|
||||||
|
if (!info.State.Running) {
|
||||||
await container.start();
|
await container.start();
|
||||||
|
}
|
||||||
return { success: true, message: `Conteneur ${containerName} démarré` };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 qu’avec deux fenêtres d’au 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -503,23 +553,16 @@ const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
|||||||
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||||
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
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_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([
|
||||||
|
|||||||
@@ -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 d’un 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 n’a 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
|
||||||
|
// 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 });
|
||||||
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,14 +144,39 @@ 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 });
|
|
||||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
|
|
||||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
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
|
||||||
@@ -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;
|
||||||
|
|||||||
61
src/backend/test/app-registry.test.js
Normal file
61
src/backend/test/app-registry.test.js
Normal 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 l’environnement 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 });
|
||||||
|
}
|
||||||
27
src/backend/test/gitea.test.js
Normal file
27
src/backend/test/gitea.test.js
Normal 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');
|
||||||
25
src/backend/test/healthcheck.test.js
Normal file
25
src/backend/test/healthcheck.test.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
14
src/backend/test/webhook.test.js
Normal file
14
src/backend/test/webhook.test.js
Normal 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');
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_ENV_NAME=Production
|
|
||||||
VITE_ENV_LABEL=Serveur de production
|
|
||||||
@@ -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
3030
src/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,6 +175,7 @@ function SyncBadge({ versionRecette, versionProd }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VersionBadge({ version, loading }) {
|
function VersionBadge({ version, loading }) {
|
||||||
|
if (!version) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
|
||||||
@@ -183,11 +184,10 @@ function VersionBadge({ version, loading }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!version) {
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 — {import.meta.env.VITE_ENV_LABEL || "Serveur de recette"}
|
Santinova Soft — Serveur de production
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user