Compare commits
15 Commits
main
...
98df3de6c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 98df3de6c7 | |||
| 5690e8d9ae | |||
|
|
17d86b5000 | ||
|
|
ea7deec23c | ||
|
|
78ba7b9f54 | ||
|
|
664963b8f7 | ||
|
|
c534a87e4c | ||
|
|
3c3708e1cb | ||
|
|
249dee2e5a | ||
|
|
312ce634b9 | ||
|
|
0a378d91ca | ||
|
|
80602797ef | ||
|
|
ff72cd9fc6 | ||
|
|
74558e6c63 | ||
|
|
ee685f0326 |
@@ -1,27 +0,0 @@
|
|||||||
name: Validation Dashboard
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
name: Tests backend et build frontend
|
|
||||||
runs-on: ci-node22
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Installer et tester le backend
|
|
||||||
working-directory: src/backend
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm test
|
|
||||||
|
|
||||||
- name: Installer et construire le frontend
|
|
||||||
working-directory: src/frontend
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Contrat `app.json`
|
|
||||||
|
|
||||||
Le dashboard et le portail applicatif utilisent exclusivement le manifeste placé dans le dossier de chaque application déployée :
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/manus-deploy/apps/<dossier-technique>/app.json
|
|
||||||
```
|
|
||||||
|
|
||||||
> Une application sans manifeste valide n’est pas considérée comme déployée. Elle n’apparaît donc ni dans le dashboard ni dans le portail.
|
|
||||||
|
|
||||||
## Schéma minimal
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "itinova-contacts",
|
|
||||||
"name": "Itinova Contacts",
|
|
||||||
"category": "ITINOVA",
|
|
||||||
"containerName": "itinova-contacts",
|
|
||||||
"giteaRepo": "itinova-contacts",
|
|
||||||
"giteaOwner": "manus-admin",
|
|
||||||
"image": "images/itinova-contacts.png",
|
|
||||||
"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
13
app.json
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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,46 +4,36 @@ services:
|
|||||||
context: ./src
|
context: ./src
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: manus-dashboard
|
container_name: manus-dashboard
|
||||||
# Redémarrage automatique après un arrêt anormal ; le processus reste borné.
|
restart: unless-stopped
|
||||||
restart: always
|
|
||||||
mem_limit: 512m
|
|
||||||
privileged: true
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
NODE_ENV: production
|
||||||
- DASHBOARD_ENV=prod
|
PORT: 3001
|
||||||
- PORT=${PORT:-3001}
|
JWT_SECRET: manus-dashboard-prod-secret-2026
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
ADMIN_USERNAME: adminItinova
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
ADMIN_PASSWORD: Itinova69!
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
GITEA_URL: https://git.santinova-soft.org
|
||||||
- GITEA_URL=${GITEA_URL}
|
GITEA_USERNAME: manus-admin
|
||||||
- GITEA_USERNAME=${GITEA_USERNAME}
|
GITEA_PASSWORD: ManusGitea2026!
|
||||||
- GITEA_PASSWORD=${GITEA_PASSWORD}
|
APPS_BASE_PATH: /opt/manus-deploy/apps
|
||||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
INFRA_BASE_PATH: /opt/manus-deploy/infrastructure
|
||||||
- GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
|
WEBHOOK_SECRET: webhook-prod-secret-2026
|
||||||
- GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
|
|
||||||
- GITEA_PASSWORD_PROD=${GITEA_PASSWORD}
|
|
||||||
- APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps}
|
|
||||||
- INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure}
|
|
||||||
# Contrôles bornés et dédoublonnés par healthcheck.js.
|
|
||||||
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
|
|
||||||
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/apps:/opt/manus-deploy/apps
|
||||||
|
- /opt/manus-deploy/infrastructure:/opt/manus-deploy/infrastructure
|
||||||
|
- /opt/manus-deploy/logs:/opt/manus-deploy/logs
|
||||||
|
# Montage /proc du VPS pour CPU (namespace partagé avec le VPS)
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
# Montage cgroup du VPS pour RAM réelle (memory.max, memory.current)
|
||||||
|
- /sys/fs/cgroup:/host/cgroup:ro
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)"
|
- "traefik.http.routers.dashboard.rule=Host(`dashboard.santinova-soft.org`)"
|
||||||
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
- "traefik.http.routers.dashboard.entrypoints=websecure"
|
||||||
- "traefik.http.routers.manus-dashboard.tls=true"
|
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
- "traefik.http.services.dashboard.loadbalancer.server.port=3001"
|
||||||
- "traefik.http.routers.manus-dashboard.service=manus-dashboard-svc"
|
|
||||||
- "traefik.http.routers.manus-dashboard.priority=100"
|
|
||||||
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
|
|
||||||
- "traefik.docker.network=web"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Supervision des applications
|
|
||||||
|
|
||||||
Le script `healthcheck-apps.sh` est exécuté par le timer systemd toutes les cinq minutes et trois minutes après chaque démarrage serveur. Il vérifie les conteneurs Docker Compose, leur healthcheck, leur politique de redémarrage et les routes HTTPS Traefik déclarées. Une tâche ponctuelle terminée avec succès est reconnue comme telle. Toute réponse HTTP inférieure à 500 confirme la joignabilité d’une route, y compris une API sans page racine ou un tableau protégé. En cas d’anomalie, il produit un rapport et une entrée de journal, **sans redémarrer de conteneur**. Les redémarrages automatiques restent assurés par les politiques Docker `restart` et par `manus-apps.service` au démarrage du serveur.
|
|
||||||
|
|
||||||
Le dernier rapport est écrit dans `/var/lib/manus-apps-health/latest.txt`. Les anomalies sont aussi consignées dans le journal système avec le tag `manus-apps-health`.
|
|
||||||
|
|
||||||
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`.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Vérifie les conteneurs Compose, leur healthcheck, leur redémarrage et leurs routes Traefik.
|
|
||||||
# Ce contrôle est volontairement non intrusif : Docker et manus-apps.service assurent les redémarrages.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly STATE_DIR="/var/lib/manus-apps-health"
|
|
||||||
readonly REPORT_FILE="${STATE_DIR}/latest.txt"
|
|
||||||
readonly LOCK_FILE="/run/manus-apps-health.lock"
|
|
||||||
readonly MODE="${1:---check-only}"
|
|
||||||
|
|
||||||
mkdir -p "$STATE_DIR"
|
|
||||||
exec 9>"$LOCK_FILE"
|
|
||||||
flock -n 9 || exit 0
|
|
||||||
|
|
||||||
declare -A SEEN_HTTP_HOSTS=()
|
|
||||||
declare -a REPORT_LINES=()
|
|
||||||
HAS_FAILURE=false
|
|
||||||
|
|
||||||
log_line() {
|
|
||||||
local level="$1"
|
|
||||||
local message="$2"
|
|
||||||
local line="[$(date -Is)] [$level] $message"
|
|
||||||
REPORT_LINES+=("$line")
|
|
||||||
echo "$line"
|
|
||||||
}
|
|
||||||
|
|
||||||
mark_failure() {
|
|
||||||
HAS_FAILURE=true
|
|
||||||
log_line "KO" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
is_healthy_http_status() {
|
|
||||||
# Toute réponse HTTP (< 500) confirme que Traefik et le service restent joignables.
|
|
||||||
# Les 401/404 sont légitimes pour les tableaux protégés ou API sans page racine.
|
|
||||||
[[ "$1" =~ ^[1-4][0-9][0-9]$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
check_http_route() {
|
|
||||||
local container_id="$1"
|
|
||||||
local project="$2"
|
|
||||||
local labels host_rule host status
|
|
||||||
|
|
||||||
labels="$(docker inspect -f '{{range $key, $value := .Config.Labels}}{{$key}}={{$value}}{{"\n"}}{{end}}' "$container_id")"
|
|
||||||
host_rule="$(printf '%s\n' "$labels" | grep -oE 'Host\(`[^`]+`\)' | head -n 1 || true)"
|
|
||||||
[[ -n "$host_rule" ]] || return 0
|
|
||||||
|
|
||||||
host="${host_rule#Host(\`}"
|
|
||||||
host="${host%\`)}"
|
|
||||||
[[ -n "$host" ]] || return 0
|
|
||||||
[[ -z "${SEEN_HTTP_HOSTS[$host]:-}" ]] || return 0
|
|
||||||
SEEN_HTTP_HOSTS["$host"]=1
|
|
||||||
|
|
||||||
status="$(curl --silent --show-error --location --max-redirs 3 --connect-timeout 5 --max-time 8 \
|
|
||||||
--output /dev/null --write-out '%{http_code}' "https://${host}" 2>/dev/null || true)"
|
|
||||||
if is_healthy_http_status "$status"; then
|
|
||||||
log_line "OK" "${project}: HTTPS ${host} → ${status}"
|
|
||||||
else
|
|
||||||
# Un échec HTTPS peut dépendre de Traefik, DNS ou d’une redirection applicative.
|
|
||||||
# Il déclenche une alerte, jamais un redémarrage Compose automatique.
|
|
||||||
mark_failure "${project}: HTTPS ${host} → ${status:-erreur réseau}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_containers() {
|
|
||||||
local container_id name project workdir config_files status health restart_policy exit_code
|
|
||||||
mapfile -t container_ids < <(docker ps -aq --filter label=com.docker.compose.project)
|
|
||||||
|
|
||||||
if [[ "${#container_ids[@]}" -eq 0 ]]; then
|
|
||||||
mark_failure "Aucun conteneur Docker Compose détecté"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
for container_id in "${container_ids[@]}"; do
|
|
||||||
name="$(docker inspect -f '{{.Name}}' "$container_id" | sed 's#^/##')"
|
|
||||||
project="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")"
|
|
||||||
workdir="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$container_id")"
|
|
||||||
config_files="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$container_id")"
|
|
||||||
status="$(docker inspect -f '{{.State.Status}}' "$container_id")"
|
|
||||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")"
|
|
||||||
restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$container_id")"
|
|
||||||
exit_code="$(docker inspect -f '{{.State.ExitCode}}' "$container_id")"
|
|
||||||
|
|
||||||
if [[ "$status" != "running" ]]; then
|
|
||||||
if [[ "$status" == "exited" && "$exit_code" == "0" ]]; then
|
|
||||||
log_line "INFO" "${project}/${name}: tâche ponctuelle terminée"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
mark_failure "${project}/${name}: état Docker ${status}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ "$health" == "unhealthy" ]]; then
|
|
||||||
mark_failure "${project}/${name}: healthcheck unhealthy"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ "$restart_policy" != "unless-stopped" && "$restart_policy" != "always" ]]; then
|
|
||||||
mark_failure "${project}/${name}: politique de redémarrage ${restart_policy:-none}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_line "OK" "${project}/${name}: running, health=${health}, restart=${restart_policy}"
|
|
||||||
check_http_route "$container_id" "$project"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
check_containers
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "Supervision Santinova / Itinova"
|
|
||||||
echo "Généré : $(date -Is)"
|
|
||||||
echo "Statut : $([[ "$HAS_FAILURE" == true ]] && echo KO || echo OK)"
|
|
||||||
printf '%s\n' "${REPORT_LINES[@]}"
|
|
||||||
} > "${REPORT_FILE}.tmp"
|
|
||||||
mv "${REPORT_FILE}.tmp" "$REPORT_FILE"
|
|
||||||
|
|
||||||
if [[ "$HAS_FAILURE" == true ]]; then
|
|
||||||
logger -p daemon.err -t manus-apps-health "Anomalie détectée : consulter ${REPORT_FILE}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
logger -p daemon.info -t manus-apps-health "Toutes les applications contrôlées sont opérationnelles"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Installe la supervision depuis une copie versionnée du dépôt manus-dashboard.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
install -D -m 0750 "${SCRIPT_DIR}/healthcheck-apps.sh" /opt/manus-deploy/scripts/healthcheck-apps.sh
|
|
||||||
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.service" /etc/systemd/system/manus-apps-health.service
|
|
||||||
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.timer" /etc/systemd/system/manus-apps-health.timer
|
|
||||||
install -D -m 0750 "${SCRIPT_DIR}/deploy-dashboard-from-host.sh" /opt/manus-deploy/scripts/deploy-dashboard-from-host.sh
|
|
||||||
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.service" /etc/systemd/system/manus-dashboard-self-deploy.service
|
|
||||||
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.path" /etc/systemd/system/manus-dashboard-self-deploy.path
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now manus-apps-health.timer
|
|
||||||
systemctl enable --now manus-dashboard-self-deploy.path
|
|
||||||
# Un échec de contrôle doit rester visible dans systemd, sans bloquer l’installation du timer.
|
|
||||||
systemctl start manus-apps-health.service || true
|
|
||||||
systemctl status manus-apps-health.service --no-pager || true
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Contrôle de santé des applications Santinova / Itinova
|
|
||||||
Documentation=https://git.santinova-soft.org/manus-admin/manus-dashboard
|
|
||||||
After=docker.service network-online.target manus-apps.service
|
|
||||||
Requires=docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/opt/manus-deploy/scripts/healthcheck-apps.sh --check-only
|
|
||||||
TimeoutStartSec=5min
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Planification de la supervision des applications Santinova / Itinova
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=3min
|
|
||||||
OnUnitActiveSec=5min
|
|
||||||
RandomizedDelaySec=30s
|
|
||||||
Persistent=true
|
|
||||||
Unit=manus-apps-health.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Surveille les demandes de redéploiement du Manus Dashboard
|
|
||||||
|
|
||||||
[Path]
|
|
||||||
PathExists=/opt/manus-deploy/apps/manus-dashboard/.deployment-request
|
|
||||||
Unit=manus-dashboard-self-deploy.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Redéploiement hôte du Manus Dashboard
|
|
||||||
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"
|
||||||
|
|
||||||
# Installer les dépendances backend depuis un verrou versionné pour un build déterministe.
|
# Copy backend
|
||||||
COPY backend/package.json backend/package-lock.json ./backend/
|
COPY backend/package.json ./backend/
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --production
|
||||||
|
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
|
||||||
|
|||||||
2483
src/backend/package-lock.json
generated
2483
src/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,7 @@
|
|||||||
"main": "src/index.js",
|
"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",
|
||||||
@@ -22,8 +21,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"adm-zip": "^0.5.10",
|
"adm-zip": "^0.5.10"
|
||||||
"ssh2": "^1.16.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 ============
|
||||||
|
|
||||||
@@ -390,29 +389,59 @@ function getContainerPort(stack, customPort) {
|
|||||||
return defaults[stack] || 3000;
|
return defaults[stack] || 3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ REGISTRE DES APPLICATIONS ============
|
// ============ APPS PERSISTENCE ============
|
||||||
|
|
||||||
/**
|
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
|
||||||
* Recharge le cache mémoire à partir des seuls manifests app.json.
|
|
||||||
* Les anciens fichiers de liste applicative ne sont plus lus : ils pouvaient
|
function loadDynamicApps() {
|
||||||
* afficher des applications non déployées et désynchroniser le portail.
|
try {
|
||||||
*/
|
if (fs.existsSync(APPS_FILE)) {
|
||||||
function initDynamicApps() {
|
const data = fs.readFileSync(APPS_FILE, 'utf-8');
|
||||||
return refreshApps(config);
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur chargement apps dynamiques:', err.message);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDynamicApps(apps) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur sauvegarde apps dynamiques:', err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Publie le manifeste d'une application créée depuis le dashboard, puis
|
|
||||||
* rafraîchit immédiatement le registre partagé par le dashboard et le portail.
|
|
||||||
*/
|
|
||||||
function addDynamicApp(appDef) {
|
function addDynamicApp(appDef) {
|
||||||
const app = writeAppManifest({
|
const apps = loadDynamicApps();
|
||||||
appsBasePath: config.appsBasePath,
|
// Éviter les doublons
|
||||||
environment: config.environment,
|
const existing = apps.findIndex((a) => a.id === appDef.id);
|
||||||
app: appDef,
|
if (existing >= 0) {
|
||||||
});
|
apps[existing] = appDef;
|
||||||
refreshApps(config);
|
} else {
|
||||||
return app;
|
apps.push(appDef);
|
||||||
|
}
|
||||||
|
saveDynamicApps(apps);
|
||||||
|
|
||||||
|
// Ajouter aussi à config.apps en mémoire
|
||||||
|
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
|
||||||
|
if (existingInConfig >= 0) {
|
||||||
|
config.apps[existingInConfig] = appDef;
|
||||||
|
} else {
|
||||||
|
config.apps.push(appDef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDynamicApps() {
|
||||||
|
const dynamicApps = loadDynamicApps();
|
||||||
|
for (const app of dynamicApps) {
|
||||||
|
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
|
||||||
|
if (existingInConfig < 0) {
|
||||||
|
config.apps.push(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ CREATION D'APPLICATION ============
|
// ============ CREATION D'APPLICATION ============
|
||||||
@@ -426,7 +455,6 @@ 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é
|
||||||
@@ -624,8 +652,6 @@ 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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -678,6 +704,7 @@ module.exports = {
|
|||||||
generateDockerCompose,
|
generateDockerCompose,
|
||||||
getAvailableStacks,
|
getAvailableStacks,
|
||||||
initDynamicApps,
|
initDynamicApps,
|
||||||
|
loadDynamicApps,
|
||||||
addDynamicApp,
|
addDynamicApp,
|
||||||
DOCKERFILE_TEMPLATES,
|
DOCKERFILE_TEMPLATES,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
* Registre des applications déployées.
|
|
||||||
*
|
|
||||||
* Source de vérité : /opt/manus-deploy/apps/<dossier>/app.json.
|
|
||||||
* Le dashboard et le portail ne doivent jamais dépendre d'une liste applicative
|
|
||||||
* codée en dur : une application sans manifeste n'est pas considérée déployée.
|
|
||||||
*/
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const 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,38 +1,194 @@
|
|||||||
/*
|
|
||||||
* 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: Number.parseInt(process.env.PORT, 10) || 3001,
|
port: process.env.PORT || 3001,
|
||||||
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
|
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
|
||||||
jwtExpiry: '24h',
|
jwtExpiry: '24h',
|
||||||
environment,
|
// Authentification
|
||||||
|
|
||||||
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 || 'https://git.santinova-soft.org',
|
||||||
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 || 'ManusGitea2026!',
|
||||||
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',
|
||||||
healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
|
// Health check interval (ms)
|
||||||
|
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
|
||||||
// Cache mémoire hydraté au démarrage puis après chaque création d'application.
|
// Apps configuration
|
||||||
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',
|
||||||
|
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: '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 Production',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,38 +139,11 @@ function gitPull(appConfig) {
|
|||||||
/**
|
/**
|
||||||
* Démarrer un conteneur Docker
|
* Démarrer un conteneur Docker
|
||||||
*/
|
*/
|
||||||
async function startContainer(containerName, appId) {
|
async function startContainer(containerName) {
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ const https = require('https');
|
|||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
|
||||||
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
||||||
// Utiliser le token API si disponible, sinon auth basique
|
|
||||||
const giteaClientHeaders = config.gitea.token
|
|
||||||
? { 'Authorization': `token ${config.gitea.token}` }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const giteaClient = axios.create({
|
const giteaClient = axios.create({
|
||||||
baseURL: `${config.gitea.url}/api/v1`,
|
baseURL: `${config.gitea.url}/api/v1`,
|
||||||
...(config.gitea.token
|
auth: {
|
||||||
? { headers: giteaClientHeaders }
|
username: config.gitea.username,
|
||||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
password: config.gitea.password,
|
||||||
),
|
},
|
||||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
@@ -70,97 +65,6 @@ async function getBranches(owner, repo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDurationSeconds(startedAt, completedAt) {
|
|
||||||
if (!startedAt || !completedAt) return null;
|
|
||||||
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
||||||
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)
|
||||||
*/
|
*/
|
||||||
@@ -204,11 +108,6 @@ module.exports = {
|
|||||||
getRepo,
|
getRepo,
|
||||||
getCommits,
|
getCommits,
|
||||||
getBranches,
|
getBranches,
|
||||||
getWorkflowRuns,
|
|
||||||
getWorkflowRunSummary,
|
|
||||||
getWorkflowRunForCommit,
|
|
||||||
normalizeWorkflowRun,
|
|
||||||
calculateCiSummary,
|
|
||||||
listRepos,
|
listRepos,
|
||||||
createRepo,
|
createRepo,
|
||||||
giteaClient,
|
giteaClient,
|
||||||
|
|||||||
@@ -3,130 +3,125 @@ const https = require('https');
|
|||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { getContainerInfo } = require('./docker');
|
const { getContainerInfo } = require('./docker');
|
||||||
|
|
||||||
// État volatile : il est régénéré au démarrage par les contrôles de santé.
|
// Store pour les statuts des apps
|
||||||
const appStatuses = new Map();
|
const appStatuses = new Map();
|
||||||
|
|
||||||
|
// Store pour les logs de déploiement
|
||||||
const deploymentLogs = [];
|
const deploymentLogs = [];
|
||||||
const giteaCommits = new Map();
|
|
||||||
const MAX_DEPLOYMENT_LOGS = 50;
|
const MAX_DEPLOYMENT_LOGS = 50;
|
||||||
const MAX_CONCURRENT_CHECKS = 4;
|
|
||||||
|
|
||||||
// Les certificats internes peuvent être auto-signés. Les URLs sont déclarées
|
// Store pour les commits Gitea
|
||||||
// dans les manifests app.json et ne sont jamais construites depuis une entrée utilisateur.
|
const giteaCommits = new Map();
|
||||||
|
|
||||||
|
// Agent HTTPS qui ignore les certificats auto-signés
|
||||||
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((entry) => entry.status === 'running');
|
const orphans = deploymentLogs.filter((l) => l.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] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
|
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`);
|
||||||
|
orphans.forEach((l) => {
|
||||||
|
l.status = 'failed';
|
||||||
|
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
|
||||||
|
l.endedAt = new Date().toISOString();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vérifie une URL applicative avec un délai borné. */
|
/**
|
||||||
|
* Vérifier la santé d'une URL
|
||||||
|
*/
|
||||||
async function checkUrl(url) {
|
async function checkUrl(url) {
|
||||||
try {
|
try {
|
||||||
const startedAt = Date.now();
|
const start = Date.now();
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
timeout: 8000,
|
timeout: 10000,
|
||||||
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: Date.now() - startedAt,
|
responseTime,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
online: false,
|
online: false,
|
||||||
statusCode: null,
|
statusCode: null,
|
||||||
responseTime: null,
|
responseTime: null,
|
||||||
error: error.message,
|
error: err.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie une application déployée sur l'environnement du dashboard courant.
|
* Vérifier la santé d'une application
|
||||||
* 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: { recette: null, prod: null },
|
health: {
|
||||||
|
recette: null,
|
||||||
|
prod: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const [containerInfo, health] = await Promise.all([
|
// Vérifier le conteneur Docker
|
||||||
getContainerInfo(appConfig.containerName),
|
const containerInfo = await getContainerInfo(appConfig.containerName);
|
||||||
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
result.container = containerInfo;
|
result.container = containerInfo;
|
||||||
result.health[environment] = health;
|
|
||||||
|
|
||||||
if (!containerInfo || !containerInfo.running) {
|
// Health check recette
|
||||||
result.status = 'offline';
|
if (appConfig.urls.recette) {
|
||||||
} else if (health?.online) {
|
result.health.recette = await checkUrl(appConfig.urls.recette);
|
||||||
result.status = 'online';
|
|
||||||
} else {
|
|
||||||
result.status = 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health check prod
|
||||||
|
if (appConfig.urls.prod) {
|
||||||
|
result.health.prod = await checkUrl(appConfig.urls.prod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déterminer le statut global
|
||||||
|
if (!containerInfo || !containerInfo.running) {
|
||||||
|
result.status = 'offline';
|
||||||
|
} else if (result.health.recette && result.health.recette.online) {
|
||||||
|
result.status = 'online';
|
||||||
|
} else if (containerInfo.running) {
|
||||||
|
result.status = 'error';
|
||||||
|
} else {
|
||||||
|
result.status = 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. */
|
/**
|
||||||
async function mapWithConcurrency(items, limit, worker) {
|
* Lancer les health checks pour toutes les apps
|
||||||
const results = new Array(items.length);
|
*/
|
||||||
let cursor = 0;
|
async function checkAllApps() {
|
||||||
|
const results = [];
|
||||||
async function runWorker() {
|
for (const app of config.apps) {
|
||||||
while (cursor < items.length) {
|
const result = await checkApp(app);
|
||||||
const index = cursor++;
|
results.push(result);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance les contrôles sans chevauchement : si un cycle est encore actif, les
|
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
|
||||||
* 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(),
|
||||||
@@ -135,25 +130,45 @@ function addDeploymentLog(appId, log) {
|
|||||||
...log,
|
...log,
|
||||||
};
|
};
|
||||||
deploymentLogs.unshift(entry);
|
deploymentLogs.unshift(entry);
|
||||||
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
|
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
|
||||||
|
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((log) => log.id === entryId);
|
const entry = deploymentLogs.find((l) => l.id === entryId);
|
||||||
if (!entry) return null;
|
if (entry) {
|
||||||
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) {
|
||||||
return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
|
if (appId) {
|
||||||
|
return deploymentLogs.filter((l) => l.appId === appId);
|
||||||
|
}
|
||||||
|
return deploymentLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer le statut d'une app
|
||||||
|
*/
|
||||||
function getAppStatus(appId) {
|
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());
|
||||||
}
|
}
|
||||||
@@ -162,7 +177,6 @@ module.exports = {
|
|||||||
checkUrl,
|
checkUrl,
|
||||||
checkApp,
|
checkApp,
|
||||||
checkAllApps,
|
checkAllApps,
|
||||||
mapWithConcurrency,
|
|
||||||
addDeploymentLog,
|
addDeploymentLog,
|
||||||
updateDeploymentLog,
|
updateDeploymentLog,
|
||||||
getDeploymentLogs,
|
getDeploymentLogs,
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ const webhookRoutes = require('./webhook');
|
|||||||
const { checkAllApps, getAllStatuses } = require('./healthcheck');
|
const { checkAllApps, getAllStatuses } = require('./healthcheck');
|
||||||
const { getCommits } = require('./gitea');
|
const { getCommits } = require('./gitea');
|
||||||
const { initDynamicApps } = require('./app-creator');
|
const { initDynamicApps } = require('./app-creator');
|
||||||
const { initSSHWebSocket } = require('./ssh');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// WebSocket server pour les mises à jour en temps réel
|
// WebSocket server pour les mises à jour en temps réel
|
||||||
const wss = new WebSocket.Server({ server, path: '/ws' });
|
const wss = new WebSocket.Server({ server, path: '/ws' });
|
||||||
initSSHWebSocket(server);
|
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
@@ -33,6 +31,10 @@ 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) => {
|
||||||
@@ -42,11 +44,6 @@ 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, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
|
const { getRepo, getCommits, getBranches, 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, category, sourceType, giteaRepoUrl } = req.body;
|
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!name || !subdomain || !stack || !sourceType) {
|
if (!name || !subdomain || !stack || !sourceType) {
|
||||||
@@ -322,7 +322,6 @@ 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,
|
||||||
@@ -399,74 +398,6 @@ 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) ============
|
|
||||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
|
||||||
router.get('/public/status', async (req, res) => {
|
|
||||||
try {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
const statuses = getAllStatuses();
|
|
||||||
const publicStatus = statuses.map((app) => ({
|
|
||||||
id: app.id,
|
|
||||||
name: app.name,
|
|
||||||
status: app.status, // 'online' | 'offline' | 'error' | 'unknown'
|
|
||||||
containerRunning: app.container ? app.container.running : false,
|
|
||||||
}));
|
|
||||||
res.json(publicStatus);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ 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) => {
|
||||||
@@ -502,7 +433,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, app.id);
|
const result = await startContainer(containerName);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -547,36 +478,48 @@ module.exports = router;
|
|||||||
|
|
||||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||||
const { exec } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
|
<<<<<<< HEAD
|
||||||
|
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'https://git.recette.santinova-soft.org';
|
||||||
|
const GITEA_PROD_EXTERNAL = process.env.GITEA_PROD_INTERNAL_URL || 'http://172.18.0.9:3000';
|
||||||
|
const GITEA_PROD_PUBLIC = process.env.GITEA_PROD_PUBLIC_URL || 'https://git.santinova-soft.org';
|
||||||
|
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||||
|
const GITEA_PASS_REC = process.env.GITEA_PASSWORD_RECETTE || 'Itinova69!';
|
||||||
|
=======
|
||||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
||||||
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
|
||||||
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
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!';
|
||||||
|
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
|
||||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
|
||||||
|
|
||||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
const INVENTORY_APPS = [
|
||||||
function getInventoryApps() {
|
{ id: 'itinova-contacts', name: 'Itinova Contacts', repoName: 'itinova-contacts' },
|
||||||
return config.apps.map((a) => ({
|
{ id: 'itinova-podcasts', name: 'Itinova Podcasts', repoName: 'itinova-podcasts' },
|
||||||
id: a.id,
|
{ id: 'veille-reglementaire', name: 'Veille Réglementaire', repoName: 'veille-reglementaire' },
|
||||||
name: a.name,
|
{ id: 'itinova-vehicle-exchange', name: 'Itinova Gestion de Flotte', repoName: 'itinova-vehicle-exchange' },
|
||||||
repoName: a.giteaRepo || a.id,
|
{ id: 'sonum', name: 'SONUM', repoName: 'sonum' },
|
||||||
}));
|
{ 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: '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) {
|
<<<<<<< HEAD
|
||||||
|
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl) {
|
||||||
|
=======
|
||||||
|
function curlGitea(baseUrl, owner, repo, pass) {
|
||||||
|
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const authHeader = token
|
const auth = `${GITEA_USER_INV}:${pass}`;
|
||||||
? `-H "Authorization: token ${token}"`
|
const cmd = `curl -sk --max-time 6 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||||
: `-u "${GITEA_USER_INV}:${pass}"`;
|
|
||||||
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
|
||||||
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
||||||
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(stdout);
|
const data = JSON.parse(stdout);
|
||||||
if (!data.id) return resolve({ present: false, url: null, version: null });
|
if (!data.id) return resolve({ present: false, url: null, version: null });
|
||||||
// Récupérer le dernier commit
|
// Récupérer le dernier commit
|
||||||
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
const cmd2 = `curl -sk --max-time 6 -u "${auth}" "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||||
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
||||||
let version = null;
|
let version = null;
|
||||||
try {
|
try {
|
||||||
@@ -589,8 +532,12 @@ function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
|||||||
version = sha && date ? `${sha} (${date})` : sha;
|
version = sha && date ? `${sha} (${date})` : sha;
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
<<<<<<< HEAD
|
||||||
const displayUrl = publicBaseUrl || baseUrl;
|
const displayUrl = publicBaseUrl || baseUrl;
|
||||||
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
||||||
|
=======
|
||||||
|
resolve({ present: true, url: `${baseUrl}/${owner}/${repo}`, version });
|
||||||
|
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
|
||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
resolve({ present: false, url: null, version: null });
|
resolve({ present: false, url: null, version: null });
|
||||||
@@ -602,12 +549,15 @@ 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([
|
||||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC),
|
||||||
|
<<<<<<< HEAD
|
||||||
|
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD, GITEA_PROD_PUBLIC),
|
||||||
|
=======
|
||||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||||
|
>>>>>>> 0781c76 (feat: page Inventaire Applications, Documentation Infra, corrections monitoring)
|
||||||
]);
|
]);
|
||||||
return { ...app, repoRecette, repoProd };
|
return { ...app, repoRecette, repoProd };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* Module SSH Terminal - Gestion des connexions SSH via WebSocket
|
|
||||||
* Utilise la bibliothèque ssh2 pour établir des connexions SSH
|
|
||||||
*/
|
|
||||||
const { Client } = require('ssh2');
|
|
||||||
const WebSocket = require('ws');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const config = require('./config');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialise le serveur WebSocket SSH sur /ws-ssh
|
|
||||||
* @param {http.Server} server - Le serveur HTTP Express
|
|
||||||
*/
|
|
||||||
function initSSHWebSocket(server) {
|
|
||||||
const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' });
|
|
||||||
|
|
||||||
wssSsh.on('connection', (ws, req) => {
|
|
||||||
// Vérifier l'authentification via le token dans l'URL
|
|
||||||
const url = new URL(req.url, 'http://localhost');
|
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' }));
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
jwt.verify(token, config.jwtSecret);
|
|
||||||
} catch (err) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' }));
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Nouvelle connexion WebSocket SSH');
|
|
||||||
|
|
||||||
let sshClient = null;
|
|
||||||
let sshStream = null;
|
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(data.toString());
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'connect':
|
|
||||||
// Établir la connexion SSH
|
|
||||||
handleConnect(ws, msg, (client, stream) => {
|
|
||||||
sshClient = client;
|
|
||||||
sshStream = stream;
|
|
||||||
connected = true;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'input':
|
|
||||||
// Envoyer des données au terminal SSH
|
|
||||||
if (sshStream && connected) {
|
|
||||||
sshStream.write(msg.data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'resize':
|
|
||||||
// Redimensionner le terminal
|
|
||||||
if (sshStream && connected) {
|
|
||||||
sshStream.setWindow(msg.rows, msg.cols, 0, 0);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnect':
|
|
||||||
// Fermer la connexion SSH
|
|
||||||
if (sshClient) {
|
|
||||||
sshClient.end();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.warn('Type de message SSH inconnu:', msg.type);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur parsing message SSH:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
console.log('Connexion WebSocket SSH fermée');
|
|
||||||
if (sshClient) {
|
|
||||||
sshClient.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
|
||||||
console.error('Erreur WebSocket SSH:', err.message);
|
|
||||||
if (sshClient) {
|
|
||||||
sshClient.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Serveur WebSocket SSH initialisé sur /ws-ssh');
|
|
||||||
return wssSsh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gère la connexion SSH
|
|
||||||
*/
|
|
||||||
function handleConnect(ws, msg, onConnected) {
|
|
||||||
const { host, port, username, password, privateKey } = msg;
|
|
||||||
|
|
||||||
if (!host || !username) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sshClient = new Client();
|
|
||||||
|
|
||||||
const connConfig = {
|
|
||||||
host: host,
|
|
||||||
port: port || 22,
|
|
||||||
username: username,
|
|
||||||
readyTimeout: 15000,
|
|
||||||
keepaliveInterval: 10000,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (privateKey) {
|
|
||||||
connConfig.privateKey = privateKey;
|
|
||||||
} else if (password) {
|
|
||||||
connConfig.password = password;
|
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` }));
|
|
||||||
|
|
||||||
sshClient.on('ready', () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` }));
|
|
||||||
|
|
||||||
sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` }));
|
|
||||||
sshClient.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnected(sshClient, stream);
|
|
||||||
|
|
||||||
// Transmettre les données du terminal SSH vers le WebSocket
|
|
||||||
stream.on('data', (data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on('data', (data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' }));
|
|
||||||
sshClient.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
sshClient.on('error', (err) => {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` }));
|
|
||||||
});
|
|
||||||
|
|
||||||
sshClient.on('end', () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
sshClient.connect(connConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initSSHWebSocket };
|
|
||||||
@@ -5,12 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { execFile } = require('child_process');
|
const { exec } = 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();
|
||||||
|
|
||||||
@@ -18,89 +15,28 @@ 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';
|
|
||||||
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
|
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
|
||||||
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
const REPO_TO_APP_MAP = {
|
||||||
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
|
'itinova-contacts': 'itinova-contacts',
|
||||||
|
'itinova-podcasts': 'itinova-podcasts',
|
||||||
|
'veille-reglementaire': 'veille-reglementaire',
|
||||||
|
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
|
||||||
|
'manus-dashboard': 'manus-dashboard',
|
||||||
|
};
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -126,14 +62,10 @@ 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 = '';
|
||||||
|
|
||||||
// Les deux serveurs partagent le module, mais leurs scripts de déploiement
|
const child = exec(cmd, { env, timeout: 600000 });
|
||||||
// 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}`); });
|
||||||
|
|
||||||
@@ -144,39 +76,14 @@ 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 } });
|
||||||
}
|
}
|
||||||
saveDeploymentStatus(appName, {
|
try {
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
|
||||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
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
|
||||||
@@ -197,21 +104,15 @@ 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}`);
|
||||||
|
|
||||||
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
|
const appName = REPO_TO_APP_MAP[repoName];
|
||||||
const app = findAppByRepo(config, repoName);
|
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
|
||||||
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({
|
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
|
||||||
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(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
|
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/webhook/status/:appName
|
// GET /api/webhook/status/:appName
|
||||||
@@ -239,6 +140,3 @@ router.get('/status', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
|
||||||
module.exports.shouldRequireCi = shouldRequireCi;
|
|
||||||
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
* Tests de non-régression du registre d'applications.
|
|
||||||
* Le registre doit faire de app.json l'unique source de vérité des applications déployées.
|
|
||||||
*/
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { discoverApps, refreshApps } = 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 });
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
const assert = require('node:assert/strict');
|
|
||||||
const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
|
|
||||||
|
|
||||||
const run = normalizeWorkflowRun({
|
|
||||||
id: 42,
|
|
||||||
name: 'Validation',
|
|
||||||
status: 'completed',
|
|
||||||
conclusion: 'success',
|
|
||||||
head_sha: 'abcdef123456',
|
|
||||||
started_at: '2026-08-18T10:00:10Z',
|
|
||||||
completed_at: '2026-08-18T10:02:15Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(run.commit, 'abcdef123456');
|
|
||||||
assert.equal(run.durationSeconds, 125);
|
|
||||||
assert.equal(run.conclusion, 'success');
|
|
||||||
|
|
||||||
const summary = calculateCiSummary([
|
|
||||||
{ status: 'completed', conclusion: 'success', durationSeconds: 90 },
|
|
||||||
{ status: 'completed', conclusion: 'success', durationSeconds: 110 },
|
|
||||||
{ status: 'completed', conclusion: 'failure', durationSeconds: 120 },
|
|
||||||
{ status: 'completed', conclusion: 'success', durationSeconds: 100 },
|
|
||||||
]);
|
|
||||||
assert.equal(summary.successRate, 75);
|
|
||||||
assert.equal(summary.medianDurationSeconds, 105);
|
|
||||||
assert.equal(summary.trend, 'stable');
|
|
||||||
console.log('OK gitea');
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/*
|
|
||||||
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
|
|
||||||
* temps, même lorsqu’un grand nombre d’applications est déclaré.
|
|
||||||
*/
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { mapWithConcurrency } = require('../src/healthcheck');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let active = 0;
|
|
||||||
let peak = 0;
|
|
||||||
const values = await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 3, async (value) => {
|
|
||||||
active += 1;
|
|
||||||
peak = Math.max(peak, active);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
active -= 1;
|
|
||||||
return value * 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(values, [2, 4, 6, 8, 10, 12, 14]);
|
|
||||||
assert.ok(peak <= 3, `concurrence observée ${peak}, maximum autorisé 3`);
|
|
||||||
console.log('OK healthcheck');
|
|
||||||
})().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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');
|
|
||||||
@@ -5,32 +5,34 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: manus-dashboard
|
container_name: manus-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- JWT_SECRET=manus-dashboard-jwt-secret-2026-recette
|
- JWT_SECRET=manus-dashboard-jwt-secret-2026-recette
|
||||||
- ADMIN_USERNAME=adminItinova
|
- ADMIN_USERNAME=adminItinova
|
||||||
- ADMIN_PASSWORD=Itinova69!
|
- ADMIN_PASSWORD=Itinova69!
|
||||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
- GITEA_URL=https://git.santinova-soft.org
|
||||||
- GITEA_USERNAME=manus-admin
|
- GITEA_USERNAME=manus-admin
|
||||||
- GITEA_PASSWORD=ManusGitea2026!
|
- GITEA_PASSWORD=ManusGitea2026!
|
||||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
|
||||||
- GITEA_RECETTE_URL=http://gitea:3000
|
|
||||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||||
|
- GITEA_RECETTE_URL=https://git.recette.santinova-soft.org
|
||||||
|
- GITEA_PASSWORD_RECETTE=Itinova69!
|
||||||
|
- GITEA_PROD_INTERNAL_URL=http://172.18.0.9:3000
|
||||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||||
- HEALTH_CHECK_INTERVAL=30000
|
- HEALTH_CHECK_INTERVAL=30000
|
||||||
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
|
||||||
extra_hosts:
|
- /proc:/host/proc:ro
|
||||||
- "git.santinova-soft.org:180.149.196.138"
|
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Route HTTPS via dashboard.recette.santinova-soft.org
|
# Route HTTPS via dashboard.santinova-soft.org
|
||||||
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
|
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)"
|
||||||
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
|
||||||
- "traefik.http.routers.manus-dashboard.tls=true"
|
- "traefik.http.routers.manus-dashboard.tls=true"
|
||||||
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
|
||||||
|
|||||||
@@ -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 - Gestion des Applications</title>
|
<title>Dashboard Production - 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
3030
src/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import GiteaPage from './pages/GiteaPage';
|
|||||||
import DockerPage from './pages/DockerPage';
|
import DockerPage from './pages/DockerPage';
|
||||||
import DocumentationPage from './pages/DocumentationPage';
|
import DocumentationPage from './pages/DocumentationPage';
|
||||||
import InventairePage from './pages/InventairePage';
|
import InventairePage from './pages/InventairePage';
|
||||||
import TerminalPage from './pages/TerminalPage';
|
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import useWebSocket from './hooks/useWebSocket';
|
import useWebSocket from './hooks/useWebSocket';
|
||||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||||
@@ -112,8 +111,6 @@ export default function App() {
|
|||||||
return <DocumentationPage />;
|
return <DocumentationPage />;
|
||||||
case 'inventaire':
|
case 'inventaire':
|
||||||
return <InventairePage />;
|
return <InventairePage />;
|
||||||
case 'terminal':
|
|
||||||
return <TerminalPage />;
|
|
||||||
default:
|
default:
|
||||||
return <DashboardPage apps={apps} />;
|
return <DashboardPage apps={apps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LayoutList, TerminalSquare,
|
import { LayoutList,
|
||||||
Server,
|
Server,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Box,
|
Box,
|
||||||
@@ -21,7 +21,6 @@ const navItems = [
|
|||||||
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
|
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
|
||||||
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
|
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
|
||||||
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
|
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
|
||||||
{ id: 'terminal', label: 'Terminal SSH', icon: TerminalSquare },
|
|
||||||
];
|
];
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
currentPage,
|
currentPage,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function DashboardPage({ apps }) {
|
|||||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||||
<span className="text-sm text-gray-400">Serveur</span>
|
<span className="text-sm text-gray-400">Serveur</span>
|
||||||
<span className="text-sm text-gray-200">
|
<span className="text-sm text-gray-200">
|
||||||
78.138.58.109 (Recette)
|
180.149.196.138 (Production)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||||
@@ -112,12 +112,12 @@ export default function DashboardPage({ apps }) {
|
|||||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||||
<span className="text-sm text-gray-400">Gitea</span>
|
<span className="text-sm text-gray-400">Gitea</span>
|
||||||
<a
|
<a
|
||||||
href="https://git.recette.santinova-soft.org"
|
href="https://git.santinova-soft.org"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-primary-400 hover:text-primary-300"
|
className="text-sm text-primary-400 hover:text-primary-300"
|
||||||
>
|
>
|
||||||
git.recette.santinova-soft.org
|
git.santinova-soft.org
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
<div className="flex items-center justify-between py-2 border-b border-dark-700">
|
||||||
@@ -147,12 +147,10 @@ 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 ${isInfra ? "bg-amber-950/20 border border-amber-500/30" : "bg-dark-700/50 border border-dark-600/50"}`}
|
className="flex items-center justify-between py-3 px-4 rounded-lg bg-dark-700/50 border border-dark-600/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -165,12 +163,7 @@ export default function DashboardPage({ apps }) {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{isInfra && (
|
<p className="text-sm font-medium text-gray-200">
|
||||||
<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">
|
||||||
@@ -187,8 +180,7 @@ export default function DashboardPage({ apps }) {
|
|||||||
<StatusBadge status={app.status} />
|
<StatusBadge status={app.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,14 +9,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
User,
|
User,
|
||||||
Code,
|
Code,
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
Loader2,
|
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
Minus,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
|
import { getGiteaRepos, getGiteaCommits } from '../utils/api';
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
@@ -33,30 +27,11 @@ 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 () => {
|
||||||
@@ -76,19 +51,11 @@ export default function GiteaPage() {
|
|||||||
setSelectedRepo(fullName);
|
setSelectedRepo(fullName);
|
||||||
try {
|
try {
|
||||||
const [owner, repo] = fullName.split('/');
|
const [owner, repo] = fullName.split('/');
|
||||||
const [commitsResponse, runsResponse, summaryResponse] = await Promise.all([
|
const res = await getGiteaCommits(owner, repo);
|
||||||
getGiteaCommits(owner, repo),
|
setCommits(res.data);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -110,12 +77,12 @@ export default function GiteaPage() {
|
|||||||
<p className="text-gray-400 mt-1">
|
<p className="text-gray-400 mt-1">
|
||||||
Explorez les dépôts sur{' '}
|
Explorez les dépôts sur{' '}
|
||||||
<a
|
<a
|
||||||
href="https://git.recette.santinova-soft.org"
|
href="https://git.santinova-soft.org"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary-400 hover:text-primary-300"
|
className="text-primary-400 hover:text-primary-300"
|
||||||
>
|
>
|
||||||
git.recette.santinova-soft.org
|
git.santinova-soft.org
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,48 +191,6 @@ 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">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react';
|
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle } from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
|
||||||
const APPS_CONFIG = [
|
const APPS_CONFIG = [
|
||||||
@@ -59,27 +59,6 @@ const APPS_CONFIG = [
|
|||||||
urlRecette: 'https://formation.recette.santinova-soft.org',
|
urlRecette: 'https://formation.recette.santinova-soft.org',
|
||||||
urlProd: 'https://formations.itinova.org',
|
urlProd: 'https://formations.itinova.org',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'pilotage-masse-salariale',
|
|
||||||
name: 'Pilotage Masse Salariale',
|
|
||||||
repoName: 'pilotage-masse-salariale',
|
|
||||||
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org',
|
|
||||||
urlProd: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'itinova-budget-si',
|
|
||||||
name: 'Gestion Budget Informatique',
|
|
||||||
repoName: 'itinova-budget-si',
|
|
||||||
urlRecette: 'https://budget-si.recette.santinova-soft.org',
|
|
||||||
urlProd: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'falc-generator',
|
|
||||||
name: 'FALC Generator',
|
|
||||||
repoName: 'falc-generator',
|
|
||||||
urlRecette: 'https://falc.recette.santinova-soft.org',
|
|
||||||
urlProd: 'https://falc.santinova-soft.org',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'portail-santinova',
|
id: 'portail-santinova',
|
||||||
name: 'Portail Applicatif',
|
name: 'Portail Applicatif',
|
||||||
@@ -89,93 +68,14 @@ const APPS_CONFIG = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manus-dashboard',
|
id: 'manus-dashboard',
|
||||||
name: 'Dashboard',
|
name: 'Dashboard Production',
|
||||||
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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
function SyncBadge({ versionRecette, versionProd }) {
|
|
||||||
// Extraire le SHA (7 premiers caractères avant l'espace)
|
|
||||||
const shaRec = versionRecette ? versionRecette.split(' ')[0] : null;
|
|
||||||
const shaProd = versionProd ? versionProd.split(' ')[0] : null;
|
|
||||||
// Extraire la date entre parenthèses pour comparer
|
|
||||||
const dateRec = versionRecette ? versionRecette.match(/\((.+?)\)/)?.[1] : null;
|
|
||||||
const dateProd = versionProd ? versionProd.match(/\((.+?)\)/)?.[1] : null;
|
|
||||||
|
|
||||||
if (!shaRec && !shaProd) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
|
|
||||||
<Minus className="w-3 h-3" />
|
|
||||||
Non déployé
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!shaRec) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title="Uniquement en production">
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
Prod en avance
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!shaProd) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Recette déployée, production vide">
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
Non déployé en prod
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (shaRec === shaProd) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-900/40 text-emerald-300 border border-emerald-700/40" title="Versions identiques">
|
|
||||||
<CheckCircle className="w-3 h-3" />
|
|
||||||
Synchronisé
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Comparer les dates pour savoir qui est en avance
|
|
||||||
// Format fr-FR : dd/mm/yyyy
|
|
||||||
const parseDate = (s) => {
|
|
||||||
if (!s) return null;
|
|
||||||
const parts = s.split('/');
|
|
||||||
if (parts.length === 3) return new Date(`${parts[2]}-${parts[1]}-${parts[0]}`);
|
|
||||||
return new Date(s);
|
|
||||||
};
|
|
||||||
const dRec = parseDate(dateRec);
|
|
||||||
const dProd = parseDate(dateProd);
|
|
||||||
|
|
||||||
if (dRec && dProd) {
|
|
||||||
if (dRec > dProd) {
|
|
||||||
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={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
Recette en avance
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (dProd > dRec) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
Prod en avance
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SHA différents mais dates non comparables
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
Divergent
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
||||||
@@ -184,10 +84,11 @@ 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 italic">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
Non déployé
|
N/A
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -289,43 +190,6 @@ export default function InventairePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{!loading && inventory.length > 0 && (
|
|
||||||
<div className="grid grid-cols-5 gap-4">
|
|
||||||
<div className="card p-4 text-center border border-dark-700">
|
|
||||||
<div className="text-2xl font-bold text-white">{inventory.length}</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">Applications totales</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-4 text-center border border-orange-700/30">
|
|
||||||
<div className="text-2xl font-bold text-orange-400">
|
|
||||||
{inventory.filter((a) => a.repoRecette?.present).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">Dépôts Recette</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-4 text-center border border-green-700/30">
|
|
||||||
<div className="text-2xl font-bold text-green-400">
|
|
||||||
{inventory.filter((a) => a.repoProd?.present).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">Dépôts Production</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-4 text-center border border-emerald-700/30">
|
|
||||||
<div className="text-2xl font-bold text-emerald-400">
|
|
||||||
{inventory.filter((a) => {
|
|
||||||
const shaRec = a.repoRecette?.version?.split(' ')[0];
|
|
||||||
const shaProd = a.repoProd?.version?.split(' ')[0];
|
|
||||||
return shaRec && shaProd && shaRec === shaProd;
|
|
||||||
}).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">Synchronisés</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-4 text-center border border-blue-700/30">
|
|
||||||
<div className="text-2xl font-bold text-blue-400">
|
|
||||||
{inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">Déployés sur les 2 envs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Légende */}
|
{/* Légende */}
|
||||||
<div className="flex items-center gap-6 text-xs text-gray-400">
|
<div className="flex items-center gap-6 text-xs text-gray-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -351,12 +215,6 @@ export default function InventairePage() {
|
|||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
|
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
|
||||||
Application
|
Application
|
||||||
</th>
|
</th>
|
||||||
<th className="text-center px-4 py-3 text-xs font-semibold text-purple-400 uppercase tracking-wider">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
Synchronisation
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="text-center px-4 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider" colSpan={2}>
|
<th className="text-center px-4 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider" colSpan={2}>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<GitBranch className="w-4 h-4" />
|
<GitBranch className="w-4 h-4" />
|
||||||
@@ -371,7 +229,6 @@ export default function InventairePage() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="border-b border-dark-700 bg-dark-800/50">
|
<tr className="border-b border-dark-700 bg-dark-800/50">
|
||||||
<th className="px-4 py-2"></th>
|
|
||||||
<th className="px-4 py-2"></th>
|
<th className="px-4 py-2"></th>
|
||||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
|
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
|
||||||
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
|
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
|
||||||
@@ -386,7 +243,7 @@ export default function InventairePage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm font-medium text-white">{app.name}</span>
|
<span className="text-sm font-medium text-white">{app.name}</span>
|
||||||
</td>
|
</td>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<td key={i} className="px-3 py-3 text-center">
|
<td key={i} className="px-3 py-3 text-center">
|
||||||
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
|
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
|
||||||
</td>
|
</td>
|
||||||
@@ -408,13 +265,6 @@ export default function InventairePage() {
|
|||||||
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
|
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Synchronisation */}
|
|
||||||
<td className="px-3 py-3 text-center">
|
|
||||||
<SyncBadge
|
|
||||||
versionRecette={app.repoRecette?.version}
|
|
||||||
versionProd={app.repoProd?.version}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/* Recette - Dépôt */}
|
{/* Recette - Dépôt */}
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<RepoBadge
|
<RepoBadge
|
||||||
@@ -453,6 +303,33 @@ export default function InventairePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{!loading && inventory.length > 0 && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="card p-4 text-center border border-dark-700">
|
||||||
|
<div className="text-2xl font-bold text-white">{inventory.length}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Applications totales</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4 text-center border border-orange-700/30">
|
||||||
|
<div className="text-2xl font-bold text-orange-400">
|
||||||
|
{inventory.filter((a) => a.repoRecette?.present).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Dépôts Recette</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4 text-center border border-green-700/30">
|
||||||
|
<div className="text-2xl font-bold text-green-400">
|
||||||
|
{inventory.filter((a) => a.repoProd?.present).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Dépôts Production</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4 text-center border border-blue-700/30">
|
||||||
|
<div className="text-2xl font-bold text-blue-400">
|
||||||
|
{inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Déployés sur les 2 envs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export default function LoginPage({ onLogin }) {
|
|||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
|
<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">
|
<h1 className="text-2xl font-bold text-white">Dashboard Production</h1>
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Terminal, Wifi, WifiOff, X, Plus, ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
// Serveurs SSH prédéfinis
|
|
||||||
const PRESET_SERVERS = [
|
|
||||||
{
|
|
||||||
id: 'recette',
|
|
||||||
label: 'Recette (78.138.58.109)',
|
|
||||||
host: '78.138.58.109',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'production',
|
|
||||||
label: 'Production (180.149.196.138)',
|
|
||||||
host: '180.149.196.138',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TerminalPage() {
|
|
||||||
const terminalRef = useRef(null);
|
|
||||||
const xtermRef = useRef(null);
|
|
||||||
const fitAddonRef = useRef(null);
|
|
||||||
const wsRef = useRef(null);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [connecting, setConnecting] = useState(false);
|
|
||||||
const [xtermLoaded, setXtermLoaded] = useState(false);
|
|
||||||
|
|
||||||
// Formulaire de connexion
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
preset: 'recette',
|
|
||||||
host: '78.138.58.109',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
password: '',
|
|
||||||
});
|
|
||||||
const [showForm, setShowForm] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Charger xterm.js dynamiquement depuis CDN
|
|
||||||
useEffect(() => {
|
|
||||||
const loadXterm = async () => {
|
|
||||||
if (window.Terminal) {
|
|
||||||
setXtermLoaded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charger le CSS xterm
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
|
|
||||||
// Charger xterm.js
|
|
||||||
await loadScript('https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js');
|
|
||||||
await loadScript('https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js');
|
|
||||||
|
|
||||||
setXtermLoaded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadXterm();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadScript = (src) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = src;
|
|
||||||
script.onload = resolve;
|
|
||||||
script.onerror = reject;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialiser xterm quand il est chargé et que le terminal est visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (!xtermLoaded || !terminalRef.current || xtermRef.current) return;
|
|
||||||
|
|
||||||
const term = new window.Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", monospace',
|
|
||||||
theme: {
|
|
||||||
background: '#0d1117',
|
|
||||||
foreground: '#e6edf3',
|
|
||||||
cursor: '#58a6ff',
|
|
||||||
cursorAccent: '#0d1117',
|
|
||||||
black: '#484f58',
|
|
||||||
red: '#ff7b72',
|
|
||||||
green: '#3fb950',
|
|
||||||
yellow: '#d29922',
|
|
||||||
blue: '#58a6ff',
|
|
||||||
magenta: '#bc8cff',
|
|
||||||
cyan: '#39c5cf',
|
|
||||||
white: '#b1bac4',
|
|
||||||
brightBlack: '#6e7681',
|
|
||||||
brightRed: '#ffa198',
|
|
||||||
brightGreen: '#56d364',
|
|
||||||
brightYellow: '#e3b341',
|
|
||||||
brightBlue: '#79c0ff',
|
|
||||||
brightMagenta: '#d2a8ff',
|
|
||||||
brightCyan: '#56d4dd',
|
|
||||||
brightWhite: '#f0f6fc',
|
|
||||||
},
|
|
||||||
scrollback: 1000,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fitAddon = new window.FitAddon.FitAddon();
|
|
||||||
term.loadAddon(fitAddon);
|
|
||||||
term.open(terminalRef.current);
|
|
||||||
fitAddon.fit();
|
|
||||||
|
|
||||||
xtermRef.current = term;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
|
|
||||||
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
|
|
||||||
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Production Santinova ║\x1b[0m');
|
|
||||||
term.writeln('\x1b[1;34m╚══════════════════════════════════════════════════╝\x1b[0m');
|
|
||||||
term.writeln('');
|
|
||||||
term.writeln('\x1b[90mSélectionnez un serveur et connectez-vous pour démarrer.\x1b[0m');
|
|
||||||
term.writeln('');
|
|
||||||
|
|
||||||
// Gérer la saisie utilisateur
|
|
||||||
term.onData((data) => {
|
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gérer le redimensionnement
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (fitAddonRef.current) {
|
|
||||||
fitAddonRef.current.fit();
|
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify({
|
|
||||||
type: 'resize',
|
|
||||||
rows: term.rows,
|
|
||||||
cols: term.cols,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (terminalRef.current) {
|
|
||||||
resizeObserver.observe(terminalRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [xtermLoaded]);
|
|
||||||
|
|
||||||
const handlePresetChange = (presetId) => {
|
|
||||||
const preset = PRESET_SERVERS.find(s => s.id === presetId);
|
|
||||||
if (preset) {
|
|
||||||
setForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
preset: presetId,
|
|
||||||
host: preset.host,
|
|
||||||
port: preset.port,
|
|
||||||
username: preset.username,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setForm(prev => ({ ...prev, preset: 'custom' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnect = useCallback(() => {
|
|
||||||
if (!form.host || !form.username || !form.password) {
|
|
||||||
setError('Hôte, utilisateur et mot de passe sont requis');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
setConnecting(true);
|
|
||||||
setShowForm(false);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('dashboard_token');
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws-ssh?token=${token}`;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'connect',
|
|
||||||
host: form.host,
|
|
||||||
port: parseInt(form.port),
|
|
||||||
username: form.username,
|
|
||||||
password: form.password,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'status':
|
|
||||||
if (xtermRef.current) {
|
|
||||||
xtermRef.current.writeln(`\x1b[33m${msg.message}\x1b[0m`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'connected':
|
|
||||||
setConnected(true);
|
|
||||||
setConnecting(false);
|
|
||||||
if (xtermRef.current) {
|
|
||||||
xtermRef.current.writeln(`\x1b[32m✓ ${msg.message}\x1b[0m`);
|
|
||||||
xtermRef.current.writeln('');
|
|
||||||
xtermRef.current.focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'output':
|
|
||||||
if (xtermRef.current) {
|
|
||||||
const data = atob(msg.data);
|
|
||||||
xtermRef.current.write(data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnected':
|
|
||||||
setConnected(false);
|
|
||||||
setConnecting(false);
|
|
||||||
if (xtermRef.current) {
|
|
||||||
xtermRef.current.writeln('');
|
|
||||||
xtermRef.current.writeln(`\x1b[33m⚠ ${msg.message}\x1b[0m`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
setConnected(false);
|
|
||||||
setConnecting(false);
|
|
||||||
setError(msg.message);
|
|
||||||
if (xtermRef.current) {
|
|
||||||
xtermRef.current.writeln(`\x1b[31m✗ Erreur: ${msg.message}\x1b[0m`);
|
|
||||||
}
|
|
||||||
setShowForm(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur parsing message SSH:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
setConnected(false);
|
|
||||||
setConnecting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
setConnected(false);
|
|
||||||
setConnecting(false);
|
|
||||||
setError('Erreur de connexion WebSocket');
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
}, [form]);
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.send(JSON.stringify({ type: 'disconnect' }));
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
setConnected(false);
|
|
||||||
setConnecting(false);
|
|
||||||
setShowForm(true);
|
|
||||||
if (xtermRef.current) {
|
|
||||||
xtermRef.current.writeln('');
|
|
||||||
xtermRef.current.writeln('\x1b[90mDéconnecté. Configurez une nouvelle connexion.\x1b[0m');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
||||||
<Terminal className="w-7 h-7 text-emerald-400" />
|
|
||||||
Terminal SSH
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-400 mt-1">
|
|
||||||
Connexion SSH sécurisée aux serveurs de l'infrastructure
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{connected ? (
|
|
||||||
<>
|
|
||||||
<span className="flex items-center gap-2 text-emerald-400 text-sm">
|
|
||||||
<Wifi className="w-4 h-4" />
|
|
||||||
Connecté à {form.host}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-600/20 text-red-400 border border-red-500/30 rounded-lg text-sm hover:bg-red-600/30 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
Déconnecter
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : connecting ? (
|
|
||||||
<span className="flex items-center gap-2 text-yellow-400 text-sm">
|
|
||||||
<div className="w-4 h-4 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
|
|
||||||
Connexion en cours...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-sm">
|
|
||||||
<WifiOff className="w-4 h-4" />
|
|
||||||
Non connecté
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Formulaire de connexion */}
|
|
||||||
{showForm && (
|
|
||||||
<div className="bg-dark-800 border border-dark-600 rounded-xl p-6">
|
|
||||||
<h3 className="text-white font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<Plus className="w-4 h-4 text-primary-400" />
|
|
||||||
Nouvelle connexion SSH
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded-lg text-red-400 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Serveur prédéfini */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Serveur prédéfini</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={form.preset}
|
|
||||||
onChange={(e) => handlePresetChange(e.target.value)}
|
|
||||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm appearance-none focus:outline-none focus:border-primary-500"
|
|
||||||
>
|
|
||||||
{PRESET_SERVERS.map(s => (
|
|
||||||
<option key={s.id} value={s.id}>{s.label}</option>
|
|
||||||
))}
|
|
||||||
<option value="custom">Serveur personnalisé...</option>
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="absolute right-3 top-3 w-4 h-4 text-gray-400 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hôte */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Hôte / IP</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.host}
|
|
||||||
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value, preset: 'custom' }))}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Port */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Port SSH</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.port}
|
|
||||||
onChange={(e) => setForm(prev => ({ ...prev, port: e.target.value }))}
|
|
||||||
placeholder="22"
|
|
||||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Utilisateur */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Utilisateur</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))}
|
|
||||||
placeholder="root"
|
|
||||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mot de passe */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Mot de passe</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
|
|
||||||
placeholder="••••••••"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
|
|
||||||
className="w-full bg-dark-700 border border-dark-500 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connecting}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
Se connecter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Terminal xterm.js */}
|
|
||||||
<div className="bg-dark-900 border border-dark-600 rounded-xl overflow-hidden">
|
|
||||||
{/* Barre de titre style terminal */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 bg-dark-800 border-b border-dark-600">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500/70" />
|
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500/70" />
|
|
||||||
<span className="ml-2 text-xs text-gray-500 font-mono">
|
|
||||||
{connected ? `${form.username}@${form.host}` : 'terminal — non connecté'}
|
|
||||||
</span>
|
|
||||||
{connected && !showForm && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="ml-auto text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
{showForm ? 'Masquer' : 'Nouvelle connexion'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zone du terminal */}
|
|
||||||
<div
|
|
||||||
ref={terminalRef}
|
|
||||||
style={{ height: '500px', padding: '8px', backgroundColor: '#0d1117' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!xtermLoaded && (
|
|
||||||
<div className="text-center text-gray-500 text-sm py-4">
|
|
||||||
Chargement du terminal...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,10 +59,6 @@ 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