Compare commits

...

30 Commits

Author SHA1 Message Date
Santinova CI
79240dd914 fix: afficher Production dans la navigation du dashboard prod 2026-08-22 09:27:58 +00:00
Santinova CI
62169d655a fix: afficher les libellés production dans le dashboard prod 2026-08-22 09:22:03 +00:00
Manus
b436176446 Merge branch 'ci-trends' 2026-08-20 18:00:18 +02:00
Manus AI
a455d3dd13 feat(ci): afficher tendances et médianes des workflows 2026-08-20 15:50:20 +00:00
Manus
d9610d2be4 feat(dashboard): distinguer visuellement les apps INFRA dans le tableau de bord 2026-08-20 16:33:07 +02:00
Manus AI
c3bf677972 fix(ci): lire les horodatages Gitea 1.25 2026-08-18 17:50:03 +00:00
Manus AI
40ac705398 chore(ops): versionner les scripts exécutables 2026-08-18 16:29:38 +00:00
Manus AI
255f54cf27 feat(ci): conditionner les déploiements et exposer les métriques 2026-08-18 16:28:39 +00:00
Manus AI
fe0a81fc98 fix(deploy): déléguer le redéploiement du dashboard à l hôte 2026-08-18 08:17:06 +00:00
Manus AI
abba8e352a feat(ops): ajouter supervision automatique des applications 2026-08-18 08:09:38 +00:00
Manus
e07e569559 refactor: fiabiliser le dashboard de production 2026-08-18 07:38:46 +00:00
Manus
b4647580fb fix: restart:always + mem_limit 512m pour stabilité dashboard 2026-07-30 10:04:39 +02:00
Manus Deploy
0b59245f76 fix: démarrer les applications absentes via compose 2026-07-24 15:36:14 +02:00
Manus Deploy
9b795612e4 feat: renforcer inventaire et configuration dashboard 2026-07-24 15:26:51 +02:00
Manus
6bfad4df8a fix: utiliser GITEA_RECETTE_TOKEN pour l'inventaire recette
La variable GITEA_TOKEN_REC utilisait process.env.GITEA_TOKEN (token Gitea prod)
au lieu de process.env.GITEA_RECETTE_TOKEN (token Gitea recette).
Cela causait l'affichage 'Absent' pour tous les dépôts recette dans l'inventaire
du dashboard de production.
2026-07-10 22:16:43 -04:00
Manus Agent
21c5457403 feat: ajout falc-generator dans config.js inventaire 2026-07-11 03:47:57 +02:00
Manus Agent
f833022f7a feat: terminal SSH, inventaire synchronisation, token Gitea, pilotage-masse-salariale
- Ajout terminal SSH intégré (xterm.js + WebSocket + ssh2)
- Colonne Synchronisation dans l inventaire (recette vs prod)
- Compteurs statistiques déplacés en haut de l inventaire
- Authentification Gitea par token API (plus d auth basique)
- URL Gitea interne stable (http://gitea:3000)
- Ajout pilotage-masse-salariale dans config.js et inventaire
- Correction lien git.recette dans GiteaPage
- Mise à jour docker-compose.yml (GITEA_TOKEN, GITEA_RECETTE_URL stable)
2026-07-11 03:19:45 +02:00
Manus
3a25eca200 feat: ajout route publique /api/public/status pour le portail applicatif 2026-06-10 05:02:53 -04:00
manus-admin
7b464c3991 fix: métriques monitoring via host-metrics.json (RAM/CPU réels hôte LXC) 2026-06-01 02:00:57 +02:00
manus-admin
ae14e23c3b fix: RAM multi-source (host/proc > proc direct > docker-api+ratio) 2026-06-01 01:50:32 +02:00
manus-admin
e6a3a0671c fix: CPU via cgroup v2 usage_usec (mesure precise conteneur LXC) + RAM formule free 2026-06-01 01:39:20 +02:00
manus-admin
2b149a1f81 fix: améliorer calcul RAM (formule free: total-free-buffers-cached) + champs buffers/cached 2026-06-01 01:35:27 +02:00
manus-admin
ed57d4af73 fix: remplacer nsenter par lecture directe /proc pour les métriques monitoring 2026-06-01 01:14:33 +02:00
manus-admin
6bae1d569d fix: fermeture return() dans inventory.map InventairePage 2026-06-01 01:06:33 +02:00
manus-admin
b40300cfc6 fix: correction syntaxe JSX InventairePage (fermeture map Array) 2026-06-01 01:02:25 +02:00
manus-admin
ea09f631d1 feat: badge Infra (amber) pour Portail et Dashboard dans AppsPage et InventairePage 2026-06-01 00:57:45 +02:00
manus-admin
c69bf3b522 fix: renommage Dashboard Recette + URL Gitea recette corrigée dans toutes les pages 2026-06-01 00:37:35 +02:00
manus-admin
04be6920a1 fix: config.js recette - ajout Formation Manager, Portail, Dashboard + URLs prod complètes pour toutes les apps 2026-06-01 00:17:22 +02:00
manus-admin
355e2a85e2 fix: URLs Gitea correctes dans inventaire (recette/prod séparées), GITEA_RECETTE_PUBLIC 2026-05-31 23:57:25 +02:00
manus-admin
0781c76931 feat: page Inventaire Applications, Documentation Infra, corrections monitoring 2026-05-31 23:35:01 +02:00
47 changed files with 8577 additions and 384 deletions

View File

@@ -0,0 +1,27 @@
name: Validation Dashboard
on:
push:
branches: [main]
paths-ignore:
- "**.md"
workflow_dispatch:
jobs:
verify:
name: Tests backend et build frontend
runs-on: ci-node22
steps:
- uses: actions/checkout@v4
- name: Installer et tester le backend
working-directory: src/backend
run: |
npm ci
npm test
- name: Installer et construire le frontend
working-directory: src/frontend
run: |
npm ci
npm run build

45
APP_MANIFEST.md Normal file
View File

@@ -0,0 +1,45 @@
# Contrat `app.json`
Le dashboard et le portail applicatif utilisent exclusivement le manifeste placé dans le dossier de chaque application déployée :
```text
/opt/manus-deploy/apps/<dossier-technique>/app.json
```
> Une application sans manifeste valide nest pas considérée comme déployée. Elle napparaît donc ni dans le dashboard ni dans le portail.
## Schéma minimal
```json
{
"id": "itinova-contacts",
"name": "Itinova Contacts",
"category": "ITINOVA",
"containerName": "itinova-contacts",
"giteaRepo": "itinova-contacts",
"giteaOwner": "manus-admin",
"image": "images/itinova-contacts.png",
"urls": {
"recette": "https://contacts.recette.santinova-soft.org",
"prod": "https://contacts.santinova-soft.org"
}
}
```
| Champ | Rôle | Règle |
|---|---|---|
| `id` | Identifiant stable de lapplication | Minuscules, chiffres et tirets uniquement. |
| `name` | Libellé affiché | Chaîne non vide. |
| `category` | Emplacement dans le portail | `ITINOVA`, `SANTINOVA` ou `INFRA`. Les applications `INFRA` restent visibles dans le dashboard mais sont exclues du portail. |
| `containerName` | Conteneur géré par Docker | Obligatoire pour le contrôle de démarrage et de statut. À défaut, le dashboard utilise `id`. |
| `giteaRepo` | Dépôt source | Sert au webhook CI/CD et à linventaire. |
| `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. |
| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. |
| `urls.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. |
| `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. |
## Règles de déploiement
Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à lapplication. Lorsquune application est promue, son manifeste de production doit contenir lURL de production avant le démarrage du dashboard.
Le dashboard ne maintient plus de liste statique dapplications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, dURL, de catégorie et de statut entre les deux interfaces.

13
app.json Normal file
View File

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

View File

@@ -4,25 +4,31 @@ services:
context: ./src
dockerfile: Dockerfile
container_name: manus-dashboard
restart: unless-stopped
# Redémarrage automatique après un arrêt anormal ; le processus reste borné.
restart: always
mem_limit: 512m
privileged: true
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3001
- JWT_SECRET=${DASHBOARD_JWT_SECRET}
- ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME}
- ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD}
- NODE_ENV=${NODE_ENV:-production}
- DASHBOARD_ENV=prod
- PORT=${PORT:-3001}
- JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GITEA_URL=${GITEA_URL}
- GITEA_USERNAME=${GITEA_USERNAME}
- GITEA_PASSWORD=${GITEA_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
- APPS_BASE_PATH=${APPS_BASE_PATH}
- INFRA_BASE_PATH=${INFRA_BASE_PATH}
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL}
- GITEA_TOKEN=${GITEA_TOKEN}
- GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
- GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
- GITEA_PASSWORD_PROD=${GITEA_PASSWORD}
- APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps}
- INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure}
# Contrôles bornés et dédoublonnés par healthcheck.js.
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
volumes:
- /opt/manus-deploy/webhook-patched.js:/app/backend/src/webhook.js:ro
- /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy
networks:
@@ -37,6 +43,7 @@ services:
- "traefik.http.routers.manus-dashboard.priority=100"
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=web"
networks:
web:
external: true

View File

@@ -0,0 +1,9 @@
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
FROM node:22-alpine
# actions/cache sappuie sur GNU tar pour archiver le store pnpm.
RUN apk add --no-cache bash git tar \
&& tar --version | grep -q 'GNU tar' \
&& corepack enable \
&& corepack prepare pnpm@10.4.1 --activate \
&& pnpm --version

17
ops/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Supervision des applications
Le script `healthcheck-apps.sh` est exécuté par le timer systemd toutes les cinq minutes et trois minutes après chaque démarrage serveur. Il vérifie les conteneurs Docker Compose, leur healthcheck, leur politique de redémarrage et les routes HTTPS Traefik déclarées. Une tâche ponctuelle terminée avec succès est reconnue comme telle. Toute réponse HTTP inférieure à 500 confirme la joignabilité dune route, y compris une API sans page racine ou un tableau protégé. En cas danomalie, il produit un rapport et une entrée de journal, **sans redémarrer de conteneur**. Les redémarrages automatiques restent assurés par les politiques Docker `restart` et par `manus-apps.service` au démarrage du serveur.
Le dernier rapport est écrit dans `/var/lib/manus-apps-health/latest.txt`. Les anomalies sont aussi consignées dans le journal système avec le tag `manus-apps-health`.
Les pushes Gitea du dépôt `manus-dashboard` ne recréent jamais le conteneur depuis lui-même. Le webhook dépose une demande atomique, surveillée par `manus-dashboard-self-deploy.path`, puis le service hôte exécute le redéploiement. Cette séparation évite larrêt du client Docker avant le démarrage du conteneur de remplacement.
Après mise à jour Git, installer ou actualiser le service avec :
```bash
sudo ./ops/install-healthcheck.sh
```
## Runner CI de production
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise limage locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Déploie le dashboard depuis lhôte systemd, jamais depuis le conteneur lui-même.
set -Eeuo pipefail
readonly APP_DIR="/opt/manus-deploy/apps/manus-dashboard"
readonly REQUEST_FILE="${APP_DIR}/.deployment-request"
readonly PROCESSING_FILE="${APP_DIR}/.deployment-request.processing"
readonly LOCK_FILE="/run/manus-dashboard-self-deploy.lock"
exec 9>"${LOCK_FILE}"
flock -n 9 || exit 0
[[ -f "${REQUEST_FILE}" ]] || exit 0
mv "${REQUEST_FILE}" "${PROCESSING_FILE}"
restore_request() {
[[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"
}
trap restore_request ERR
cd "${APP_DIR}"
git pull --ff-only origin main
docker compose up -d --build
rm -f "${PROCESSING_FILE}"
logger -p daemon.info -t manus-dashboard-self-deploy "Dashboard redéployé depuis le service hôte"

View File

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

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Runner Gitea Actions de production
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/manus-deploy/gitea-runner
Environment=DOCKER_HOST=unix:///var/run/docker.sock
ExecStart=/usr/local/bin/act_runner daemon --config /opt/manus-deploy/gitea-runner/config.yaml
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target

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

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Vérifie les conteneurs Compose, leur healthcheck, leur redémarrage et leurs routes Traefik.
# Ce contrôle est volontairement non intrusif : Docker et manus-apps.service assurent les redémarrages.
set -Eeuo pipefail
readonly STATE_DIR="/var/lib/manus-apps-health"
readonly REPORT_FILE="${STATE_DIR}/latest.txt"
readonly LOCK_FILE="/run/manus-apps-health.lock"
readonly MODE="${1:---check-only}"
mkdir -p "$STATE_DIR"
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
declare -A SEEN_HTTP_HOSTS=()
declare -a REPORT_LINES=()
HAS_FAILURE=false
log_line() {
local level="$1"
local message="$2"
local line="[$(date -Is)] [$level] $message"
REPORT_LINES+=("$line")
echo "$line"
}
mark_failure() {
HAS_FAILURE=true
log_line "KO" "$1"
}
is_healthy_http_status() {
# Toute réponse HTTP (< 500) confirme que Traefik et le service restent joignables.
# Les 401/404 sont légitimes pour les tableaux protégés ou API sans page racine.
[[ "$1" =~ ^[1-4][0-9][0-9]$ ]]
}
check_http_route() {
local container_id="$1"
local project="$2"
local labels host_rule host status
labels="$(docker inspect -f '{{range $key, $value := .Config.Labels}}{{$key}}={{$value}}{{"\n"}}{{end}}' "$container_id")"
host_rule="$(printf '%s\n' "$labels" | grep -oE 'Host\(`[^`]+`\)' | head -n 1 || true)"
[[ -n "$host_rule" ]] || return 0
host="${host_rule#Host(\`}"
host="${host%\`)}"
[[ -n "$host" ]] || return 0
[[ -z "${SEEN_HTTP_HOSTS[$host]:-}" ]] || return 0
SEEN_HTTP_HOSTS["$host"]=1
status="$(curl --silent --show-error --location --max-redirs 3 --connect-timeout 5 --max-time 8 \
--output /dev/null --write-out '%{http_code}' "https://${host}" 2>/dev/null || true)"
if is_healthy_http_status "$status"; then
log_line "OK" "${project}: HTTPS ${host}${status}"
else
# Un échec HTTPS peut dépendre de Traefik, DNS ou dune redirection applicative.
# Il déclenche une alerte, jamais un redémarrage Compose automatique.
mark_failure "${project}: HTTPS ${host}${status:-erreur réseau}"
fi
}
check_containers() {
local container_id name project workdir config_files status health restart_policy exit_code
mapfile -t container_ids < <(docker ps -aq --filter label=com.docker.compose.project)
if [[ "${#container_ids[@]}" -eq 0 ]]; then
mark_failure "Aucun conteneur Docker Compose détecté"
return
fi
for container_id in "${container_ids[@]}"; do
name="$(docker inspect -f '{{.Name}}' "$container_id" | sed 's#^/##')"
project="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")"
workdir="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$container_id")"
config_files="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$container_id")"
status="$(docker inspect -f '{{.State.Status}}' "$container_id")"
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")"
restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$container_id")"
exit_code="$(docker inspect -f '{{.State.ExitCode}}' "$container_id")"
if [[ "$status" != "running" ]]; then
if [[ "$status" == "exited" && "$exit_code" == "0" ]]; then
log_line "INFO" "${project}/${name}: tâche ponctuelle terminée"
continue
fi
mark_failure "${project}/${name}: état Docker ${status}"
continue
fi
if [[ "$health" == "unhealthy" ]]; then
mark_failure "${project}/${name}: healthcheck unhealthy"
continue
fi
if [[ "$restart_policy" != "unless-stopped" && "$restart_policy" != "always" ]]; then
mark_failure "${project}/${name}: politique de redémarrage ${restart_policy:-none}"
continue
fi
log_line "OK" "${project}/${name}: running, health=${health}, restart=${restart_policy}"
check_http_route "$container_id" "$project"
done
}
check_containers
{
echo "Supervision Santinova / Itinova"
echo "Généré : $(date -Is)"
echo "Statut : $([[ "$HAS_FAILURE" == true ]] && echo KO || echo OK)"
printf '%s\n' "${REPORT_LINES[@]}"
} > "${REPORT_FILE}.tmp"
mv "${REPORT_FILE}.tmp" "$REPORT_FILE"
if [[ "$HAS_FAILURE" == true ]]; then
logger -p daemon.err -t manus-apps-health "Anomalie détectée : consulter ${REPORT_FILE}"
exit 1
fi
logger -p daemon.info -t manus-apps-health "Toutes les applications contrôlées sont opérationnelles"

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

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
set -Eeuo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
readonly GITEA_CONTAINER="gitea"
readonly GITEA_URL="https://git.santinova-soft.org"
readonly RUNNER_NAME="production-docker-runner"
readonly RUNNER_IMAGE="gitea-runner-node:22"
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
readonly LABEL_FILE="${RUNNER_DIR}/labels"
install -d -m 0750 "${RUNNER_DIR}" "${RUNNER_DIR}/cache"
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
systemctl stop gitea-act-runner.service 2>/dev/null || true
rm -f "${RUNNER_DIR}/.runner"
token="$(docker exec -u git "${GITEA_CONTAINER}" gitea actions generate-runner-token)"
(
cd "${RUNNER_DIR}"
/usr/local/bin/act_runner register --no-interactive \
--instance "${GITEA_URL}" \
--token "${token}" \
--name "${RUNNER_NAME}" \
--labels "${RUNNER_LABELS}" \
--config "${RUNNER_DIR}/config.yaml"
)
printf '%s\n' "${RUNNER_LABELS}" > "${LABEL_FILE}"
fi
systemctl daemon-reload
systemctl enable --now gitea-act-runner.service
systemctl is-active gitea-act-runner.service

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

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Installe la supervision depuis une copie versionnée du dépôt manus-dashboard.
set -Eeuo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
install -D -m 0750 "${SCRIPT_DIR}/healthcheck-apps.sh" /opt/manus-deploy/scripts/healthcheck-apps.sh
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.service" /etc/systemd/system/manus-apps-health.service
install -D -m 0644 "${SCRIPT_DIR}/manus-apps-health.timer" /etc/systemd/system/manus-apps-health.timer
install -D -m 0750 "${SCRIPT_DIR}/deploy-dashboard-from-host.sh" /opt/manus-deploy/scripts/deploy-dashboard-from-host.sh
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.service" /etc/systemd/system/manus-dashboard-self-deploy.service
install -D -m 0644 "${SCRIPT_DIR}/manus-dashboard-self-deploy.path" /etc/systemd/system/manus-dashboard-self-deploy.path
systemctl daemon-reload
systemctl enable --now manus-apps-health.timer
systemctl enable --now manus-dashboard-self-deploy.path
# Un échec de contrôle doit rester visible dans systemd, sans bloquer linstallation du timer.
systemctl start manus-apps-health.service || true
systemctl status manus-apps-health.service --no-pager || true

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Contrôle de santé des applications Santinova / Itinova
Documentation=https://git.santinova-soft.org/manus-admin/manus-dashboard
After=docker.service network-online.target manus-apps.service
Requires=docker.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/manus-deploy/scripts/healthcheck-apps.sh --check-only
TimeoutStartSec=5min
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Planification de la supervision des applications Santinova / Itinova
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
Unit=manus-apps-health.service
[Install]
WantedBy=timers.target

View File

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

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redéploiement hôte du Manus Dashboard
After=docker.service network-online.target
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/opt/manus-deploy/scripts/deploy-dashboard-from-host.sh
Restart=on-failure
RestartSec=1min
TimeoutStartSec=10min
StandardOutput=journal
StandardError=journal

View File

@@ -25,10 +25,10 @@ RUN apk add --no-cache docker-cli docker-cli-compose git curl unzip bash openssh
RUN git config --global user.email "admin@santinova-soft.org" && \
git config --global user.name "Manus Dashboard"
# Copy backend
COPY backend/package.json ./backend/
# Installer les dépendances backend depuis un verrou versionné pour un build déterministe.
COPY backend/package.json backend/package-lock.json ./backend/
WORKDIR /app/backend
RUN npm install --production
RUN npm ci --omit=dev
COPY backend/ ./

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "src/index.js",
"scripts": {
"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": {
"express": "^4.21.0",
@@ -21,7 +22,8 @@
"cookie-parser": "^1.4.6",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"adm-zip": "^0.5.10"
"adm-zip": "^0.5.10",
"ssh2": "^1.16.0"
},
"devDependencies": {
"nodemon": "^3.1.0"

View File

@@ -4,6 +4,7 @@ const path = require('path');
const { exec, execSync } = require('child_process');
const config = require('./config');
const { createRepo, giteaClient } = require('./gitea');
const { refreshApps, writeAppManifest } = require('./app-registry');
// ============ TEMPLATES DOCKERFILE ============
@@ -389,59 +390,29 @@ function getContainerPort(stack, customPort) {
return defaults[stack] || 3000;
}
// ============ APPS PERSISTENCE ============
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
function loadDynamicApps() {
try {
if (fs.existsSync(APPS_FILE)) {
const data = fs.readFileSync(APPS_FILE, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error('Erreur chargement apps dynamiques:', err.message);
}
return [];
}
function saveDynamicApps(apps) {
try {
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
} catch (err) {
console.error('Erreur sauvegarde apps dynamiques:', err.message);
}
}
function addDynamicApp(appDef) {
const apps = loadDynamicApps();
// Éviter les doublons
const existing = apps.findIndex((a) => a.id === appDef.id);
if (existing >= 0) {
apps[existing] = appDef;
} else {
apps.push(appDef);
}
saveDynamicApps(apps);
// Ajouter aussi à config.apps en mémoire
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
if (existingInConfig >= 0) {
config.apps[existingInConfig] = appDef;
} else {
config.apps.push(appDef);
}
}
// ============ REGISTRE DES APPLICATIONS ============
/**
* Recharge le cache mémoire à partir des seuls manifests app.json.
* Les anciens fichiers de liste applicative ne sont plus lus : ils pouvaient
* afficher des applications non déployées et désynchroniser le portail.
*/
function initDynamicApps() {
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);
return refreshApps(config);
}
}
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
/**
* Publie le manifeste d'une application créée depuis le dashboard, puis
* rafraîchit immédiatement le registre partagé par le dashboard et le portail.
*/
function addDynamicApp(appDef) {
const app = writeAppManifest({
appsBasePath: config.appsBasePath,
environment: config.environment,
app: appDef,
});
refreshApps(config);
return app;
}
// ============ CREATION D'APPLICATION ============
@@ -455,6 +426,7 @@ async function createApplication(params, logCallback) {
port,
needsDb,
dbType,
category,
sourceType, // 'zip' or 'gitea'
giteaRepoUrl, // URL du repo Gitea existant
zipFilePath, // Chemin du fichier ZIP uploadé
@@ -652,6 +624,8 @@ async function createApplication(params, logCallback) {
stack: stack,
needsDb: needsDb || false,
dbType: dbType || null,
category: category || 'SANTINOVA',
image: `images/${appId}.png`,
createdAt: new Date().toISOString(),
};
@@ -704,7 +678,6 @@ module.exports = {
generateDockerCompose,
getAvailableStacks,
initDynamicApps,
loadDynamicApps,
addDynamicApp,
DOCKERFILE_TEMPLATES,
};

View File

@@ -0,0 +1,160 @@
/*
* Registre des applications déployées.
*
* Source de vérité : /opt/manus-deploy/apps/<dossier>/app.json.
* Le dashboard et le portail ne doivent jamais dépendre d'une liste applicative
* codée en dur : une application sans manifeste n'est pas considérée déployée.
*/
const fs = require('fs');
const path = require('path');
const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
const ENVIRONMENTS = new Set(['recette', 'prod']);
function isHttpsUrl(value) {
if (typeof value !== 'string' || value.length === 0) return false;
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
/**
* Valide puis normalise un manifeste app.json.
* Les champs dérivés (directory et healthCheckUrl) ne sont pas écrits dans le
* manifeste : ils sont calculés depuis le dossier et l'environnement courant.
*/
function normalizeManifest(manifest, directory, environment) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('le manifeste doit être un objet JSON');
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(manifest.id || '')) {
throw new Error('id absent ou invalide');
}
if (typeof manifest.name !== 'string' || manifest.name.trim().length === 0) {
throw new Error('name absent ou invalide');
}
if (!CATEGORIES.has(manifest.category)) {
throw new Error('category doit être ITINOVA, SANTINOVA ou INFRA');
}
if (!manifest.urls || typeof manifest.urls !== 'object') {
throw new Error('urls absent ou invalide');
}
if (!isHttpsUrl(manifest.urls[environment])) {
throw new Error(`urls.${environment} absent ou invalide`);
}
if (manifest.ci !== undefined && (
!manifest.ci || typeof manifest.ci !== 'object' || Array.isArray(manifest.ci) ||
(manifest.ci.required !== undefined && typeof manifest.ci.required !== 'boolean')
)) {
throw new Error('ci doit être un objet avec une propriété required booléenne');
}
return {
...manifest,
name: manifest.name.trim(),
directory,
containerName: manifest.containerName || manifest.id,
healthCheckUrl: manifest.urls[environment],
ci: { required: manifest.ci?.required === true },
};
}
/**
* Découvre les applications depuis le système de fichiers sans modifier la
* configuration mémoire. Les erreurs de manifeste sont retournées explicitement
* afin qu'un manifeste défaillant ne fasse pas tomber le dashboard.
*/
function discoverApps({ appsBasePath, environment }) {
if (!ENVIRONMENTS.has(environment)) {
throw new Error(`Environnement non supporté : ${environment}`);
}
const apps = [];
const errors = [];
let entries = [];
try {
entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
} catch (error) {
return { apps, errors: [{ directory: appsBasePath, error: error.message }] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(appsBasePath, entry.name, 'app.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const raw = fs.readFileSync(manifestPath, 'utf8');
const manifest = normalizeManifest(JSON.parse(raw), entry.name, environment);
apps.push(manifest);
} catch (error) {
errors.push({ directory: entry.name, error: error.message });
}
}
apps.sort((left, right) => left.name.localeCompare(right.name, 'fr'));
return { apps, errors };
}
/**
* Actualise le cache mémoire utilisé par les routes et les contrôles de santé.
* Toute valeur obsolète est remplacée : seules les applications réellement
* déployées et correctement déclarées restent disponibles.
*/
function refreshApps(config, logger = console) {
const result = discoverApps({
appsBasePath: config.appsBasePath,
environment: config.environment,
});
config.apps.splice(0, config.apps.length, ...result.apps);
for (const issue of result.errors) {
logger.warn(`[app-registry] Manifeste ignoré (${issue.directory}) : ${issue.error}`);
}
logger.info(`[app-registry] ${result.apps.length} application(s) déployée(s) détectée(s)`);
return result;
}
/**
* Écrit un manifeste de manière atomique puis le valide avant publication.
*/
function writeAppManifest({ appsBasePath, environment, app }) {
const directory = app.directory || app.id;
const appDirectory = path.join(appsBasePath, directory);
const normalized = normalizeManifest({ ...app, directory: undefined, healthCheckUrl: undefined }, directory, environment);
const manifestPath = path.join(appDirectory, 'app.json');
const temporaryPath = `${manifestPath}.tmp`;
fs.mkdirSync(appDirectory, { recursive: true });
fs.writeFileSync(temporaryPath, `${JSON.stringify({ ...normalized, directory: undefined, healthCheckUrl: undefined }, null, 2)}\n`, 'utf8');
fs.renameSync(temporaryPath, manifestPath);
return normalized;
}
function findAppDirectoryByRepo(config, repositoryName) {
const app = findAppByRepo(config, repositoryName);
return app ? app.directory : null;
}
/**
* Retourne le manifeste complet afin que le webhook applique les règles de
* promotion déclarées par l'application (notamment ci.required).
*/
function findAppByRepo(config, repositoryName) {
const { apps } = refreshApps(config);
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
}
module.exports = {
discoverApps,
refreshApps,
writeAppManifest,
findAppDirectoryByRepo,
findAppByRepo,
};

View File

@@ -1,114 +1,38 @@
/*
* Configuration du dashboard.
*
* Les métadonnées des applications ne sont volontairement pas définies ici.
* Elles sont lues exclusivement depuis /opt/manus-deploy/apps/<app>/app.json
* par app-registry.js, ce qui évite les divergences entre dashboard et portail.
*/
const environment = process.env.DASHBOARD_ENV || 'recette';
if (!['recette', 'prod'].includes(environment)) {
throw new Error('DASHBOARD_ENV doit valoir "recette" ou "prod"');
}
module.exports = {
port: process.env.PORT || 3001,
port: Number.parseInt(process.env.PORT, 10) || 3001,
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
jwtExpiry: '24h',
// Authentification
environment,
auth: {
username: process.env.ADMIN_USERNAME || 'adminItinova',
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
},
// Gitea
gitea: {
url: process.env.GITEA_URL || 'https://git.santinova-soft.org',
url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin',
password: process.env.GITEA_PASSWORD || 'ManusGitea2026!',
password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null,
},
// Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms)
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration - can be extended
apps: [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
description: 'Application de gestion des contacts',
directory: 'itinova-contacts',
giteaRepo: 'itinova-contacts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: null,
},
containerName: 'itinova-contacts',
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
port: 3000,
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
description: 'Application de gestion de podcasts',
directory: 'itinova-podcasts',
giteaRepo: 'itinova-podcasts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://podcasts.recette.santinova-soft.org',
prod: null,
},
containerName: 'itinova-podcasts',
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
port: 3000,
},
{
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: null,
},
containerName: 'veille-reglementaire',
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
port: 3000,
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
description: 'Application de gestion de flotte de véhicules',
directory: 'itinova-vehicle-exchange',
giteaRepo: 'itinova-vehicle-exchange',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://flotte.recette.santinova-soft.org',
prod: null,
},
containerName: 'itinova-vehicle-exchange',
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
port: 3000,
},
{
id: 'sonum',
name: 'Sonum',
description: 'Application Sonum',
directory: 'sonum',
giteaRepo: 'sonum',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://sonum.recette.santinova-soft.org',
prod: null,
},
containerName: 'sonum',
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
port: 3000,
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
description: 'Application de gestion de la facturation',
directory: 'facturation-santinova',
giteaRepo: 'facturation-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://facturation.recette.santinova-soft.org',
prod: null,
},
containerName: 'facturation-santinova-app',
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
port: 3001,
},
],
healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
// Cache mémoire hydraté au démarrage puis après chaque création d'application.
apps: [],
};

View File

@@ -139,11 +139,38 @@ function gitPull(appConfig) {
/**
* Démarrer un conteneur Docker
*/
async function startContainer(containerName) {
async function startContainer(containerName, appId) {
try {
// Tenter d'abord un docker start (conteneur existant mais arrêté)
const container = docker.getContainer(containerName);
const info = await container.inspect().catch(() => null);
if (info) {
// Le conteneur existe, on le démarre
if (!info.State.Running) {
await container.start();
}
return { success: true, message: `Conteneur ${containerName} démarré` };
}
// Le conteneur n'existe pas : docker compose up dans le répertoire de l'app
const id = appId || containerName;
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const appDir = path.join(appsBasePath, id);
const fs = require('fs');
const composeFile = fs.existsSync(path.join(appDir, 'docker-compose.yml'))
? path.join(appDir, 'docker-compose.yml')
: fs.existsSync(path.join(appDir, 'docker-compose.prod.yml'))
? path.join(appDir, 'docker-compose.prod.yml')
: null;
if (!composeFile) {
return { success: false, message: `Aucun docker-compose.yml trouvé dans ${appDir}` };
}
await new Promise((resolve, reject) => {
exec(`docker compose -f ${composeFile} up -d`, { cwd: appDir }, (err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message));
else resolve(stdout);
});
});
return { success: true, message: `Conteneur ${containerName} créé et démarré via docker compose` };
} catch (err) {
return { success: false, message: err.message };
}
@@ -181,67 +208,177 @@ async function restartContainer(containerName) {
function getServerMetrics() {
return new Promise((resolve) => {
const { execSync } = require("child_process");
const fs = require("fs");
try {
// nsenter -t 1 -m lit les métriques réelles de l'hôte VPS (pas de l'hyperviseur)
const ns = "nsenter -t 1 -m --";
// ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte =====
// Ce fichier est mis à jour toutes les 30s par /opt/manus-deploy/host-metrics.sh
// Il contient les vraies métriques de l'hôte LXC (pas du nœud physique)
const hostMetricsPath = '/opt/manus-deploy/host-metrics.json';
if (fs.existsSync(hostMetricsPath)) {
try {
const raw = fs.readFileSync(hostMetricsPath, 'utf8');
const hostMetrics = JSON.parse(raw);
// Vérifier que le fichier est récent (moins de 2 minutes)
const fileAge = Date.now() - new Date(hostMetrics.timestamp).getTime();
if (fileAge < 120000 && hostMetrics.memory && hostMetrics.memory.total > 0) {
return resolve({
...hostMetrics,
timestamp: new Date().toISOString()
});
}
} catch(e) {}
}
// CPU : delta /proc/stat sur 200ms
// ===== Méthode 2 : Lecture directe de /proc (fallback) =====
// CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC)
// Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible
let cpuUsage = 0;
try {
const s1 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/);
execSync("sleep 0.2");
const s2 = execSync(`${ns} cat /proc/stat | grep '^cpu '`).toString().trim().split(/\s+/);
const idle1 = parseInt(s1[4]), total1 = s1.slice(1).reduce((a,b)=>a+parseInt(b),0);
const idle2 = parseInt(s2[4]), total2 = s2.slice(1).reduce((a,b)=>a+parseInt(b),0);
const ncpu = parseInt(execSync('nproc').toString().trim()) || 1;
const cgroupPath = '/sys/fs/cgroup/cpu.stat';
const readUsageUsec = () => {
const stat = fs.readFileSync(cgroupPath, 'utf8');
const m = stat.match(/^usage_usec\s+(\d+)/m);
return m ? parseInt(m[1]) : null;
};
const u1 = readUsageUsec();
if (u1 !== null) {
// Méthode cgroup v2 : mesure la consommation CPU réelle du conteneur
execSync('sleep 0.5');
const u2 = readUsageUsec();
// delta en microsecondes / (500ms * ncpu) = % CPU du conteneur
const deltaUs = u2 - u1;
cpuUsage = Math.min(100, Math.round(deltaUs / (500000 * ncpu) * 100));
} else {
// Fallback : /proc/stat normalisé par nproc
const parseCpuLine = (line) => line.trim().split(/\s+/).slice(1).map(Number);
const s1 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
execSync('sleep 0.3');
const s2 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
const idle1 = s1[3], total1 = s1.reduce((a,b)=>a+b,0);
const idle2 = s2[3], total2 = s2.reduce((a,b)=>a+b,0);
const dIdle = idle2 - idle1, dTotal = total2 - total1;
cpuUsage = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
} catch(e) {
const rawCpu = dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0;
// Normaliser par nproc pour éviter l'effet nœud physique sur LXC
cpuUsage = Math.round(rawCpu / ncpu);
}
} catch(e) {}
// RAM : lecture robuste multi-source
// Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose)
// Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct)
// Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique
let memTotal = 0;
let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0;
// Fonction de lecture de meminfo depuis un chemin donné
const readMeminfo = (procPath) => {
const raw = fs.readFileSync(procPath + '/meminfo', 'utf8');
const map = {};
raw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.substring(0, idx).trim();
const rest = line.substring(idx + 1).trim();
const val = parseInt(rest.split(' ')[0]);
if (Number.isFinite(val)) map[key] = val * 1024;
}
});
return map;
};
try {
const cpuRaw = execSync(`${ns} top -bn1 | grep 'Cpu(s)' | awk '{print $2}'`).toString().trim();
cpuUsage = parseFloat(cpuRaw) || 0;
let memMap = null;
let sourceLabel = '';
// Priorité 1 : /host/proc monté depuis l'hôte LXC
if (fs.existsSync('/host/proc/meminfo')) {
try {
const hostMap = readMeminfo('/host/proc');
if (hostMap['MemTotal'] > 0 && hostMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = hostMap;
sourceLabel = 'host/proc';
}
} catch(e) {}
}
// Priorité 2 : /proc direct si MemTotal <= 64 Go
if (!memMap) {
try {
const procMap = readMeminfo('/proc');
if (procMap['MemTotal'] > 0 && procMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
memMap = procMap;
sourceLabel = '/proc direct';
}
} catch(e) {}
}
// Priorité 3 : Docker API MemTotal + ratio du nœud physique
if (!memMap) {
try {
const dockerInfoRaw = execSync(
'curl -s --unix-socket /var/run/docker.sock http://localhost/info',
{ timeout: 3000 }
).toString();
const dockerInfo = JSON.parse(dockerInfoRaw);
const dockerMemTotal = dockerInfo.MemTotal || 0;
if (dockerMemTotal > 0) {
// Lire le ratio d'utilisation depuis /proc du nœud physique
const nodeMap = readMeminfo('/proc');
const nodeTotal = nodeMap['MemTotal'] || 1;
const nodeAvail = nodeMap['MemAvailable'] || 0;
const usageRatio = (nodeTotal - nodeAvail) / nodeTotal;
// Appliquer ce ratio à la vraie RAM du VPS
memTotal = dockerMemTotal;
memUsed = Math.round(memTotal * usageRatio);
memFree = memTotal - memUsed;
memAvailable = memFree;
sourceLabel = 'docker-api+ratio';
}
} catch(e) {}
}
// Calculer les valeurs finales si memMap disponible
if (memMap) {
memTotal = memMap['MemTotal'] || 0;
memFree = memMap['MemFree'] || 0;
memAvailable = memMap['MemAvailable'] || 0;
memBuffers = memMap['Buffers'] || 0;
const sReclaimable = memMap['SReclaimable'] || 0;
const shmem = memMap['Shmem'] || 0;
memCached = (memMap['Cached'] || 0) + (sReclaimable > shmem ? sReclaimable - shmem : 0);
// Utiliser MemAvailable comme référence car elle inclut les caches libérables
// Identique à ce que `free` affiche dans la colonne 'disponible'
memUsed = memTotal - memAvailable;
if (memUsed < 0) memUsed = 0;
}
} catch(e) {
// Fallback ultime
try {
const m = readMeminfo('/proc');
memTotal = m['MemTotal'] || 0;
memFree = m['MemFree'] || 0;
memAvailable = m['MemAvailable'] || 0;
memUsed = memTotal - memAvailable;
} catch(e2) {}
}
// RAM : cgroup de l'hôte (fiable même en environnement VPS virtualisé)
// La RAM totale est lue depuis /sys/fs/cgroup/memory.max de l'hôte
// La RAM utilisée est calculée via docker stats (valeurs réelles du VPS)
let memTotal = 8589934592; // 8 Go par défaut
// Disque : df sur /opt/manus-deploy (point de montage de l'hôte LXC)
// Utiliser /opt/manus-deploy car c'est un volume monté depuis l'hôte
let diskTotal = 0, diskUsed = 0, diskFree = 0;
try {
const cgroupMax = execSync(`${ns} cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null`).toString().trim();
if (cgroupMax && cgroupMax !== 'max' && !isNaN(parseInt(cgroupMax))) {
memTotal = parseInt(cgroupMax);
}
const diskPath = fs.existsSync('/opt/manus-deploy') ? '/opt/manus-deploy' : '/';
const diskRaw = execSync('df -B1 ' + diskPath + ' | tail -1').toString().trim().split(/\s+/);
diskTotal = parseInt(diskRaw[1]);
diskUsed = parseInt(diskRaw[2]);
diskFree = parseInt(diskRaw[3]);
} catch(e) {}
// RAM utilisée : somme de tous les conteneurs via docker stats
let memUsed = 0;
try {
const statsRaw = execSync("docker stats --no-stream --format '{{.MemUsage}}'").toString().trim();
statsRaw.split('\n').forEach(line => {
const match = line.match(/([\d.]+)(\w+)\s*\//);
if (match) {
const val = parseFloat(match[1]);
const unit = match[2].toLowerCase();
if (unit === 'gib') memUsed += val * 1024 * 1024 * 1024;
else if (unit === 'mib') memUsed += val * 1024 * 1024;
else if (unit === 'kib') memUsed += val * 1024;
else memUsed += val;
}
});
} catch(e) {}
const memAvailable = memTotal - memUsed;
const memFree = memAvailable;
// Disque : df sur l'hôte
const diskRaw = execSync(`${ns} df -B1 / | tail -1`).toString().trim().split(/\s+/);
const diskTotal = parseInt(diskRaw[1]);
const diskUsed = parseInt(diskRaw[2]);
const diskFree = parseInt(diskRaw[3]);
// Uptime et load
const uptimeRaw = execSync(`${ns} cat /proc/uptime`).toString().trim().split(" ");
// Uptime et load : utiliser /host/proc si disponible (hôte LXC réel)
const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc';
const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' ');
const uptimeSeconds = parseFloat(uptimeRaw[0]);
const loadRaw = execSync(`${ns} cat /proc/loadavg`).toString().trim().split(" ");
const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' ');
const load1 = parseFloat(loadRaw[0]);
const load5 = parseFloat(loadRaw[1]);
const load15 = parseFloat(loadRaw[2]);
@@ -249,9 +386,14 @@ function getServerMetrics() {
// Réseau
let netRx = 0, netTx = 0;
try {
const netRaw = execSync(`${ns} cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1`).toString().trim().split(/\s+/);
netRx = parseInt(netRaw[1]) || 0;
netTx = parseInt(netRaw[9]) || 0;
const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev';
const netDev = fs.readFileSync(netDevPath, 'utf8');
const netLine = netDev.split('\n').find(l => /eth0|ens|enp/.test(l));
if (netLine) {
const parts = netLine.trim().split(/\s+/);
netRx = parseInt(parts[1]) || 0;
netTx = parseInt(parts[9]) || 0;
}
} catch(e) {}
resolve({
@@ -261,6 +403,9 @@ function getServerMetrics() {
used: memUsed,
free: memFree,
available: memAvailable,
buffers: memBuffers,
cached: memCached,
// usagePercent = RAM applicative réelle (hors cache disque, identique à `free`)
usagePercent: Math.round((memUsed / memTotal) * 100)
},
disk: {

View File

@@ -3,12 +3,17 @@ const https = require('https');
const config = require('./config');
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
// Utiliser le token API si disponible, sinon auth basique
const giteaClientHeaders = config.gitea.token
? { 'Authorization': `token ${config.gitea.token}` }
: {};
const giteaClient = axios.create({
baseURL: `${config.gitea.url}/api/v1`,
auth: {
username: config.gitea.username,
password: config.gitea.password,
},
...(config.gitea.token
? { headers: giteaClientHeaders }
: { auth: { username: config.gitea.username, password: config.gitea.password } }
),
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
timeout: 10000,
});
@@ -65,6 +70,97 @@ async function getBranches(owner, repo) {
}
}
function toDurationSeconds(startedAt, completedAt) {
if (!startedAt || !completedAt) return null;
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
}
/** Normalise les exécutions CI pour le webhook et le dashboard. */
function normalizeWorkflowRun(run) {
// Gitea 1.25 renvoie started_at/completed_at (et non les champs GitHub).
// Les aliases conservent la compatibilité avec déventuelles versions futures.
const startedAt = run.started_at || run.run_started_at || null;
const completedAt = run.completed_at || run.run_completed_at || run.updated_at || null;
return {
id: run.id,
name: run.name || run.workflow_id || 'Validation CI',
status: run.status || 'unknown',
conclusion: run.conclusion || null,
event: run.event || null,
commit: run.head_sha || null,
createdAt: run.created_at || null,
startedAt,
completedAt,
durationSeconds: toDurationSeconds(startedAt, completedAt),
url: run.html_url || null,
};
}
async function getWorkflowRuns(owner, repo, limit = 10) {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
params: { limit, page: 1 },
});
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
} catch (err) {
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
return [];
}
}
async function getWorkflowRunForCommit(owner, repo, commitHash) {
const runs = await getWorkflowRuns(owner, repo, 30);
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
}
/** Retourne la médiane d'une série numérique non vide. */
function median(values) {
if (!values.length) return null;
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
}
/**
* Construit une synthèse déterministe sur les derniers runs CI.
* Les comparaisons ne sont calculées quavec deux fenêtres dau moins deux runs
* pour ne pas transformer une variation isolée en tendance.
*/
function calculateCiSummary(runs) {
const completed = runs.filter((run) => run.status === 'completed');
const successful = completed.filter((run) => run.conclusion === 'success');
const durations = completed.map((run) => run.durationSeconds).filter(Number.isFinite);
const windowSize = Math.floor(durations.length / 2);
const recent = windowSize >= 2 ? durations.slice(0, windowSize) : [];
const previous = windowSize >= 2 ? durations.slice(windowSize, windowSize * 2) : [];
const recentMedian = median(recent);
const previousMedian = median(previous);
const changePercent = previousMedian && recentMedian !== null
? Math.round(((recentMedian - previousMedian) / previousMedian) * 100)
: null;
const trend = changePercent === null ? 'unknown'
: changePercent <= -10 ? 'faster'
: changePercent >= 10 ? 'slower'
: 'stable';
return {
sampleSize: completed.length,
successful: successful.length,
failed: completed.length - successful.length,
successRate: completed.length ? Math.round((successful.length / completed.length) * 100) : null,
medianDurationSeconds: median(durations),
recentMedianDurationSeconds: recentMedian,
previousMedianDurationSeconds: previousMedian,
trend,
changePercent,
};
}
async function getWorkflowRunSummary(owner, repo) {
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
}
/**
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
*/
@@ -108,6 +204,11 @@ module.exports = {
getRepo,
getCommits,
getBranches,
getWorkflowRuns,
getWorkflowRunSummary,
getWorkflowRunForCommit,
normalizeWorkflowRun,
calculateCiSummary,
listRepos,
createRepo,
giteaClient,

View File

@@ -3,125 +3,130 @@ const https = require('https');
const config = require('./config');
const { getContainerInfo } = require('./docker');
// Store pour les statuts des apps
// État volatile : il est régénéré au démarrage par les contrôles de santé.
const appStatuses = new Map();
// Store pour les logs de déploiement
const deploymentLogs = [];
const MAX_DEPLOYMENT_LOGS = 50;
// Store pour les commits Gitea
const giteaCommits = new Map();
const MAX_DEPLOYMENT_LOGS = 50;
const MAX_CONCURRENT_CHECKS = 4;
// Agent HTTPS qui ignore les certificats auto-signés
// Les certificats internes peuvent être auto-signés. Les URLs sont déclarées
// dans les manifests app.json et ne sont jamais construites depuis une entrée utilisateur.
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
let runningHealthCheck = null;
// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed"
// (ils ont été interrompus lors du dernier redémarrage du Dashboard)
function cleanupOrphanedDeployments() {
const orphans = deploymentLogs.filter((l) => l.status === 'running');
const orphans = deploymentLogs.filter((entry) => entry.status === 'running');
for (const entry of orphans) {
entry.status = 'failed';
entry.message = 'Déploiement interrompu (redémarrage du Dashboard)';
entry.endedAt = new Date().toISOString();
}
if (orphans.length > 0) {
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();
});
console.log(`[healthcheck] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
}
}
/**
* Vérifier la santé d'une URL
*/
/** Vérifie une URL applicative avec un délai borné. */
async function checkUrl(url) {
try {
const start = Date.now();
const startedAt = Date.now();
const response = await axios.get(url, {
timeout: 10000,
timeout: 8000,
httpsAgent,
validateStatus: (status) => status < 500,
});
const responseTime = Date.now() - start;
return {
online: true,
statusCode: response.status,
responseTime,
responseTime: Date.now() - startedAt,
};
} catch (err) {
} catch (error) {
return {
online: false,
statusCode: null,
responseTime: null,
error: err.message,
error: error.message,
};
}
}
/**
* Vérifier la santé d'une application
* Vérifie une application déployée sur l'environnement du dashboard courant.
* Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite
* de mélanger les états des environnements et divise les appels HTTP inutiles.
*/
async function checkApp(appConfig) {
const environment = config.environment;
const localUrl = appConfig.urls?.[environment];
const result = {
id: appConfig.id,
name: appConfig.name,
description: appConfig.description,
description: appConfig.description || '',
urls: appConfig.urls,
containerName: appConfig.containerName,
lastCheck: new Date().toISOString(),
status: 'unknown',
container: null,
health: {
recette: null,
prod: null,
},
health: { recette: null, prod: null },
};
// Vérifier le conteneur Docker
const containerInfo = await getContainerInfo(appConfig.containerName);
const [containerInfo, health] = await Promise.all([
getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
]);
result.container = containerInfo;
result.health[environment] = health;
// Health check recette
if (appConfig.urls.recette) {
result.health.recette = await checkUrl(appConfig.urls.recette);
}
// Health check prod
if (appConfig.urls.prod) {
result.health.prod = await checkUrl(appConfig.urls.prod);
}
// Déterminer le statut global
if (!containerInfo || !containerInfo.running) {
result.status = 'offline';
} else if (result.health.recette && result.health.recette.online) {
} else if (health?.online) {
result.status = 'online';
} else if (containerInfo.running) {
result.status = 'error';
} else {
result.status = 'offline';
result.status = 'error';
}
// Stocker le résultat
appStatuses.set(appConfig.id, result);
return result;
}
/**
* Lancer les health checks pour toutes les apps
*/
async function checkAllApps() {
const results = [];
for (const app of config.apps) {
const result = await checkApp(app);
results.push(result);
/** Exécute un lot asynchrone avec une concurrence bornée. */
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
async function runWorker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await worker(items[index]);
}
}
const workerCount = Math.min(Math.max(limit, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, runWorker));
return results;
}
/**
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
* Lance les contrôles sans chevauchement : si un cycle est encore actif, les
* consommateurs récupèrent sa même promesse au lieu de redoubler les requêtes.
*/
function checkAllApps() {
if (runningHealthCheck) return runningHealthCheck;
runningHealthCheck = mapWithConcurrency(config.apps, MAX_CONCURRENT_CHECKS, checkApp)
.catch((error) => {
console.error('[healthcheck] Échec du cycle :', error.message);
throw error;
})
.finally(() => {
runningHealthCheck = null;
});
return runningHealthCheck;
}
function addDeploymentLog(appId, log) {
const entry = {
id: Date.now().toString(),
@@ -130,45 +135,25 @@ function addDeploymentLog(appId, log) {
...log,
};
deploymentLogs.unshift(entry);
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
deploymentLogs.pop();
}
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
return entry;
}
/**
* Mettre à jour un log de déploiement existant par son id
* Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée
*/
function updateDeploymentLog(entryId, updates) {
const entry = deploymentLogs.find((l) => l.id === entryId);
if (entry) {
const entry = deploymentLogs.find((log) => log.id === entryId);
if (!entry) return null;
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry;
}
return null;
}
/**
* Récupérer les logs de déploiement
*/
function getDeploymentLogs(appId = null) {
if (appId) {
return deploymentLogs.filter((l) => l.appId === appId);
}
return deploymentLogs;
return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
}
/**
* Récupérer le statut d'une app
*/
function getAppStatus(appId) {
return appStatuses.get(appId) || null;
}
/**
* Récupérer tous les statuts
*/
function getAllStatuses() {
return Array.from(appStatuses.values());
}
@@ -177,6 +162,7 @@ module.exports = {
checkUrl,
checkApp,
checkAllApps,
mapWithConcurrency,
addDeploymentLog,
updateDeploymentLog,
getDeploymentLogs,

View File

@@ -15,12 +15,14 @@ const webhookRoutes = require('./webhook');
const { checkAllApps, getAllStatuses } = require('./healthcheck');
const { getCommits } = require('./gitea');
const { initDynamicApps } = require('./app-creator');
const { initSSHWebSocket } = require('./ssh');
const app = express();
const server = http.createServer(app);
// WebSocket server pour les mises à jour en temps réel
const wss = new WebSocket.Server({ server, path: '/ws' });
initSSHWebSocket(server);
// Middleware
app.use(helmet({
@@ -31,10 +33,6 @@ app.use(cors({
origin: true,
credentials: true,
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// Route webhook montée en premier avec raw body pour la vérification HMAC
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
@@ -44,6 +42,11 @@ app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' })
}
next();
}, webhookRoutes);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// API routes
app.use('/api', routes);

View File

@@ -4,7 +4,7 @@ const path = require('path');
const fs = require('fs');
const { authenticate, authMiddleware } = require('./auth');
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const config = require('./config');
@@ -256,7 +256,7 @@ router.get('/apps-config/stacks', authMiddleware, (req, res) => {
// Créer une nouvelle application
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
try {
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
const { name, description, subdomain, stack, port, needsDb, dbType, category, sourceType, giteaRepoUrl } = req.body;
// Validation
if (!name || !subdomain || !stack || !sourceType) {
@@ -322,6 +322,7 @@ router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), asy
port: port ? parseInt(port) : null,
needsDb: needsDb === 'true' || needsDb === true,
dbType: dbType || 'mysql',
category: category || 'SANTINOVA',
sourceType,
giteaRepoUrl,
zipFilePath: req.file ? req.file.path : null,
@@ -398,6 +399,74 @@ 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 ============
router.get('/docker/containers', authMiddleware, async (req, res) => {
@@ -433,7 +502,7 @@ router.post('/apps/:id/start', authMiddleware, async (req, res) => {
const app = config.apps.find((a) => a.id === req.params.id);
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
const containerName = app.containerName || app.id;
const result = await startContainer(containerName);
const result = await startContainer(containerName, app.id);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
@@ -475,3 +544,78 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
});
module.exports = router;
// ===== INVENTAIRE DES APPLICATIONS =====
const { exec } = require('child_process');
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
function getInventoryApps() {
return config.apps.map((a) => ({
id: a.id,
name: a.name,
repoName: a.giteaRepo || a.id,
}));
}
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
return new Promise((resolve) => {
const authHeader = token
? `-H "Authorization: token ${token}"`
: `-u "${GITEA_USER_INV}:${pass}"`;
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
exec(cmd, { timeout: 7000 }, (err, stdout) => {
if (err || !stdout) return resolve({ present: false, url: null, version: null });
try {
const data = JSON.parse(stdout);
if (!data.id) return resolve({ present: false, url: null, version: null });
// Récupérer le dernier commit
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
let version = null;
try {
const commits = JSON.parse(stdout2 || '[]');
if (commits.length > 0) {
const c = commits[0];
const sha = c.sha ? c.sha.substring(0, 7) : null;
const date = c.commit && c.commit.author && c.commit.author.date
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
version = sha && date ? `${sha} (${date})` : sha;
}
} catch(e) {}
const displayUrl = publicBaseUrl || baseUrl;
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
});
} catch(e) {
resolve({ present: false, url: null, version: null });
}
});
});
}
router.get('/inventory', authMiddleware, async (req, res) => {
try {
const owner = GITEA_USER_INV;
const INVENTORY_APPS = getInventoryApps();
const results = await Promise.all(
INVENTORY_APPS.map(async (app) => {
const [repoRecette, repoProd] = await Promise.all([
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
]);
return { ...app, repoRecette, repoProd };
})
);
res.json(results);
} catch (err) {
console.error('Erreur /inventory:', err.message);
res.status(500).json({ error: err.message });
}
});
;

179
src/backend/src/ssh.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* 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 };

View File

@@ -5,9 +5,12 @@
*/
const express = require('express');
const crypto = require('crypto');
const { exec } = require('child_process');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const { findAppByRepo } = require('./app-registry');
const { getWorkflowRunForCommit } = require('./gitea');
const router = express.Router();
@@ -15,28 +18,89 @@ const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy';
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
const REPO_TO_APP_MAP = {
'itinova-contacts': 'itinova-contacts',
'itinova-podcasts': 'itinova-podcasts',
'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard',
};
const DASHBOARD_APP_ID = 'manus-dashboard';
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
// Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set();
/**
* Le conteneur ne peut pas se recréer lui-même : Docker interrompt son client
* `docker compose` au moment où le conteneur courant est arrêté. Ce cas est
* donc délégué au service systemd hôte manus-dashboard-self-deploy.path.
*/
function isHostManagedDeployment(appName) {
return appName === DASHBOARD_APP_ID;
}
function requestDashboardHostDeployment({ branch, commitHash, committer }) {
const temporaryRequest = `${DASHBOARD_DEPLOY_REQUEST}.${process.pid}.tmp`;
const request = {
branch,
commitHash,
committer,
requestedAt: new Date().toISOString(),
};
fs.writeFileSync(temporaryRequest, JSON.stringify(request, null, 2), { mode: 0o600 });
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
}
function shouldRequireCi(app) {
return app?.ci?.required === true;
}
function saveDeploymentStatus(appName, status) {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify(status, null, 2));
} catch (error) {
console.error('[Webhook] Erreur sauvegarde statut:', error.message);
}
}
function wait(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
/**
* Une application déclarant ci.required ne peut être déployée que si le commit
* reçu dispose dun run Gitea terminé avec succès.
*/
async function waitForSuccessfulCi(app, commitHash) {
const owner = app.giteaOwner || 'manus-admin';
const repo = app.giteaRepo || app.id;
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
let lastRun = null;
while (Date.now() < deadline) {
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
if (lastRun?.status === 'completed') {
return {
allowed: lastRun.conclusion === 'success',
run: lastRun,
reason: lastRun.conclusion === 'success'
? null
: `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
};
}
await wait(CI_GATE_POLL_INTERVAL_MS);
}
return {
allowed: false,
run: lastRun,
reason: lastRun ? 'La CI na pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
};
}
function verifyGiteaSignature(req) {
if (!WEBHOOK_SECRET) {
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
return true;
}
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
@@ -62,10 +126,14 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
}
const env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = '';
const child = exec(cmd, { env, timeout: 600000 });
// Les deux serveurs partagent le module, mais leurs scripts de déploiement
// nattendent pas le même second argument : recette attend son environnement,
// production attend la branche Git. Cette sélection évite tout mélange.
const deployTarget = config.environment === 'prod' ? 'main' : 'recette';
// execFile évite toute interprétation shell du nom de dossier issu du manifeste.
const child = execFile('bash', [DEPLOY_SCRIPT, appName, deployTarget], { env, timeout: 600000 });
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
@@ -76,14 +144,39 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
if (broadcast) {
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
}
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
saveDeploymentStatus(appName, {
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
timestamp: new Date().toISOString(), output: output.slice(-3000),
}, null, 2));
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
});
});
}
async function gateAndDeploy(app, branch, commitHash, committer, broadcast) {
if (shouldRequireCi(app)) {
const ci = await waitForSuccessfulCi(app, commitHash);
if (!ci.allowed) {
const status = {
app: app.directory,
branch,
commit: commitHash,
committer,
status: 'blocked',
timestamp: new Date().toISOString(),
ci: ci.run,
reason: ci.reason,
};
console.warn(`[Webhook] Déploiement bloqué pour ${app.directory}: ${ci.reason}`);
saveDeploymentStatus(app.directory, status);
broadcast?.({ type: 'deploy_blocked', data: status });
return;
}
}
if (isHostManagedDeployment(app.directory)) {
requestDashboardHostDeployment({ branch, commitHash, committer });
return;
}
runDeploy(app.directory, branch, commitHash, committer, broadcast);
}
// POST /api/webhook/gitea
@@ -104,15 +197,21 @@ router.post('/gitea', (req, res) => {
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName];
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
const app = findAppByRepo(config, repoName);
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
res.status(202).json({
message: shouldRequireCi(app) ? `Validation CI requise avant déploiement de ${app.directory}` : `Déploiement de ${app.directory} déclenché`,
commit: commitHash,
branch,
ciRequired: shouldRequireCi(app),
});
// Récupérer la fonction broadcast si disponible globalement
const broadcastFn = global.wsBroadcast || null;
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
setImmediate(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
});
// GET /api/webhook/status/:appName
@@ -140,3 +239,6 @@ router.get('/status', (req, res) => {
});
module.exports = router;
module.exports.isHostManagedDeployment = isHostManagedDeployment;
module.exports.shouldRequireCi = shouldRequireCi;
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;

View File

@@ -0,0 +1,61 @@
/*
* Tests de non-régression du registre d'applications.
* Le registre doit faire de app.json l'unique source de vérité des applications déployées.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { discoverApps, refreshApps } = require('../src/app-registry');
function writeManifest(basePath, directory, manifest) {
const appPath = path.join(basePath, directory);
fs.mkdirSync(appPath, { recursive: true });
fs.writeFileSync(path.join(appPath, 'app.json'), JSON.stringify(manifest), 'utf8');
}
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'app-registry-'));
try {
writeManifest(sandbox, 'contacts', {
id: 'itinova-contacts',
name: 'Itinova Contacts',
category: 'ITINOVA',
containerName: 'itinova-contacts',
image: 'images/itinova-contacts.png',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
},
});
writeManifest(sandbox, 'invalide', {
id: 'app-invalide',
name: 'Application invalide',
category: 'INCONNUE',
urls: { recette: 'https://invalide.recette.santinova-soft.org' },
});
const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' });
assert.equal(discovery.apps.length, 1, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée');
const app = discovery.apps[0];
assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste');
assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler lenvironnement courant');
assert.equal(app.containerName, 'itinova-contacts');
const config = {
appsBasePath: sandbox,
environment: 'recette',
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
};
const refresh = refreshApps(config);
assert.equal(refresh.apps.length, 1, 'les applications non déployées ne doivent pas rester en cache');
assert.equal(config.apps[0].name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique');
console.log('OK app-registry');
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@@ -0,0 +1,27 @@
const assert = require('node:assert/strict');
const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
const run = normalizeWorkflowRun({
id: 42,
name: 'Validation',
status: 'completed',
conclusion: 'success',
head_sha: 'abcdef123456',
started_at: '2026-08-18T10:00:10Z',
completed_at: '2026-08-18T10:02:15Z',
});
assert.equal(run.commit, 'abcdef123456');
assert.equal(run.durationSeconds, 125);
assert.equal(run.conclusion, 'success');
const summary = calculateCiSummary([
{ status: 'completed', conclusion: 'success', durationSeconds: 90 },
{ status: 'completed', conclusion: 'success', durationSeconds: 110 },
{ status: 'completed', conclusion: 'failure', durationSeconds: 120 },
{ status: 'completed', conclusion: 'success', durationSeconds: 100 },
]);
assert.equal(summary.successRate, 75);
assert.equal(summary.medianDurationSeconds, 105);
assert.equal(summary.trend, 'stable');
console.log('OK gitea');

View File

@@ -0,0 +1,25 @@
/*
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
* temps, même lorsquun grand nombre dapplications est déclaré.
*/
const assert = require('node:assert/strict');
const { mapWithConcurrency } = require('../src/healthcheck');
(async () => {
let active = 0;
let peak = 0;
const values = await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 3, async (value) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
return value * 2;
});
assert.deepEqual(values, [2, 4, 6, 8, 10, 12, 14]);
assert.ok(peak <= 3, `concurrence observée ${peak}, maximum autorisé 3`);
console.log('OK healthcheck');
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
/*
* Un conteneur ne peut pas recréer fiablement son propre conteneur Docker :
* le client `docker compose` est arrêté en même temps que lui. Le webhook doit
* donc déléguer ce cas précis à un service systemd hôte.
*/
const assert = require('node:assert/strict');
const { isHostManagedDeployment, shouldRequireCi } = require('../src/webhook');
assert.equal(isHostManagedDeployment('manus-dashboard'), true);
assert.equal(isHostManagedDeployment('itinova-contacts'), false);
assert.equal(shouldRequireCi({ ci: { required: true } }), true);
assert.equal(shouldRequireCi({ ci: { required: false } }), false);
assert.equal(shouldRequireCi({}), false);
console.log('OK webhook');

View File

@@ -11,21 +11,26 @@ services:
- JWT_SECRET=manus-dashboard-jwt-secret-2026-recette
- ADMIN_USERNAME=adminItinova
- ADMIN_PASSWORD=Itinova69!
- GITEA_URL=https://git.santinova-soft.org
- GITEA_URL=https://git.recette.santinova-soft.org
- GITEA_USERNAME=manus-admin
- GITEA_PASSWORD=ManusGitea2026!
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
- GITEA_RECETTE_URL=http://gitea:3000
- GITEA_PASSWORD_PROD=ManusGitea2026!
- APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
- HEALTH_CHECK_INTERVAL=30000
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy
extra_hosts:
- "git.santinova-soft.org:180.149.196.138"
networks:
- web
labels:
- "traefik.enable=true"
# Route HTTPS via dashboard.santinova-soft.org
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)"
# Route HTTPS via dashboard.recette.santinova-soft.org
- "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`)"
- "traefik.http.routers.manus-dashboard.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"

View File

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

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

View File

@@ -6,16 +6,17 @@ import MonitoringPage from './pages/MonitoringPage';
import DeploymentsPage from './pages/DeploymentsPage';
import GiteaPage from './pages/GiteaPage';
import DockerPage from './pages/DockerPage';
import DocumentationPage from './pages/DocumentationPage';
import InventairePage from './pages/InventairePage';
import TerminalPage from './pages/TerminalPage';
import Sidebar from './components/Sidebar';
import useWebSocket from './hooks/useWebSocket';
import { getMe, getApps, logout as apiLogout } from './utils/api';
export default function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState('dashboard');
const [apps, setApps] = useState([]);
// Vérifier l'authentification au chargement
useEffect(() => {
const token = localStorage.getItem('dashboard_token');
@@ -34,7 +35,6 @@ export default function App() {
setLoading(false);
}
}, []);
const fetchApps = async () => {
try {
const res = await getApps();
@@ -43,23 +43,19 @@ export default function App() {
console.error('Erreur chargement apps:', err);
}
};
// WebSocket handler
const handleWsMessage = useCallback((data) => {
if (data.type === 'health_update') {
setApps(data.data);
}
}, []);
const { connected: wsConnected } = useWebSocket(
authenticated ? handleWsMessage : null
);
const handleLogin = (data) => {
setAuthenticated(true);
fetchApps();
};
const handleLogout = async () => {
try {
await apiLogout();
@@ -70,7 +66,6 @@ export default function App() {
setAuthenticated(false);
setApps([]);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-950">
@@ -96,11 +91,9 @@ export default function App() {
</div>
);
}
if (!authenticated) {
return <LoginPage onLogin={handleLogin} />;
}
const renderPage = () => {
switch (currentPage) {
case 'dashboard':
@@ -115,11 +108,16 @@ export default function App() {
return <DockerPage />;
case 'monitoring':
return <MonitoringPage />;
case 'documentation':
return <DocumentationPage />;
case 'inventaire':
return <InventairePage />;
case 'terminal':
return <TerminalPage />;
default:
return <DashboardPage apps={apps} />;
}
};
return (
<div className="min-h-screen bg-dark-950">
<Sidebar

View File

@@ -117,8 +117,19 @@ export default function AppCard({ app, commits, onRefresh }) {
const latestCommit = commits && commits.length > 0 ? commits[0] : null;
// Apps d'infrastructure : portail et dashboard
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return (
<div className="card overflow-hidden">
<div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
{/* Badge infra */}
{isInfra && (
<div className="px-6 pt-3 pb-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
Infrastructure
</span>
</div>
)}
{/* Header */}
<div className="p-6 border-b border-dark-700">
<div className="flex items-start justify-between">

View File

@@ -1,5 +1,5 @@
import React from 'react';
import {
import { LayoutList, TerminalSquare,
Server,
LayoutDashboard,
Box,
@@ -10,8 +10,8 @@ import {
Wifi,
WifiOff,
Activity,
BookOpen,
} from 'lucide-react';
const navItems = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'apps', label: 'Applications', icon: Box },
@@ -19,8 +19,10 @@ const navItems = [
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
{ id: 'docker', label: 'Docker', icon: Container },
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
{ id: 'terminal', label: 'Terminal SSH', icon: TerminalSquare },
];
export default function Sidebar({
currentPage,
onNavigate,
@@ -36,24 +38,28 @@ export default function Sidebar({
<Server className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Manus</h1>
<p className="text-xs text-gray-500">Dashboard</p>
<h1 className="text-lg font-bold text-white">Dashboard</h1>
<p className="text-xs text-gray-500">Production</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isDoc = item.id === 'documentation';
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-primary-600/10 text-primary-400 border border-primary-500/20'
? isDoc
? 'bg-purple-600/10 text-purple-400 border border-purple-500/20'
: 'bg-primary-600/10 text-primary-400 border border-primary-500/20'
: isDoc
? 'text-purple-400/70 hover:text-purple-300 hover:bg-purple-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-dark-800'
}`}
>
@@ -63,7 +69,6 @@ export default function Sidebar({
);
})}
</nav>
{/* Footer */}
<div className="p-4 border-t border-dark-700 space-y-3">
{/* WebSocket status */}
@@ -80,7 +85,6 @@ export default function Sidebar({
</>
)}
</div>
<button
onClick={onLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"

View File

@@ -112,12 +112,12 @@ export default function DashboardPage({ apps }) {
<div className="flex items-center justify-between py-2 border-b border-dark-700">
<span className="text-sm text-gray-400">Gitea</span>
<a
href="https://git.santinova-soft.org"
href="https://git.recette.santinova-soft.org"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-400 hover:text-primary-300"
>
git.santinova-soft.org
git.recette.santinova-soft.org
</a>
</div>
<div className="flex items-center justify-between py-2 border-b border-dark-700">
@@ -147,10 +147,12 @@ export default function DashboardPage({ apps }) {
<p className="text-gray-500 text-sm">Aucune application détectée</p>
) : (
<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
key={app.id}
className="flex items-center justify-between py-3 px-4 rounded-lg bg-dark-700/50 border border-dark-600/50"
className={`flex items-center justify-between py-3 px-4 rounded-lg ${isInfra ? "bg-amber-950/20 border border-amber-500/30" : "bg-dark-700/50 border border-dark-600/50"}`}
>
<div className="flex items-center gap-3">
<div
@@ -163,7 +165,12 @@ export default function DashboardPage({ apps }) {
}`}
/>
<div>
<p className="text-sm font-medium text-gray-200">
{isInfra && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 mb-0.5">
⚙ Infra
</span>
)}
<p className={`text-sm font-medium ${isInfra ? "text-amber-200" : "text-gray-200"}`}>
{app.name}
</p>
<p className="text-xs text-gray-500">
@@ -180,7 +187,8 @@ export default function DashboardPage({ apps }) {
<StatusBadge status={app.status} />
</div>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -0,0 +1,420 @@
import React, { useState } from 'react';
import {
BookOpen,
Server,
GitBranch,
LayoutDashboard,
Globe,
Database,
Shield,
ArrowRight,
ChevronDown,
ChevronUp,
ExternalLink,
Activity,
Cpu,
HardDrive,
Network,
CheckCircle,
Clock,
Zap,
Package,
} from 'lucide-react';
/* ─────────────────────────────────────────────
Données de l'infrastructure
───────────────────────────────────────────── */
const RECETTE = {
label: 'RECETTE',
color: 'amber',
ip: '78.138.58.109',
os: 'Debian GNU/Linux 12',
cpu: '4 vCores',
ram: '8 Go',
disk: '147 Go',
infra: [
{
icon: GitBranch,
color: 'purple',
name: 'Gitea',
url: 'git.recette.santinova-soft.org',
desc: 'Dépôt Git — 4 repositories',
href: 'https://git.recette.santinova-soft.org',
},
{
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.recette.santinova-soft.org',
desc: 'Monitoring CPU / RAM / Disque',
href: 'https://dashboard.recette.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.recette.santinova-soft.org',
desc: 'Vitrine centralisée des applications',
href: 'https://portail.recette.santinova-soft.org',
},
{
icon: Shield,
color: 'gray',
name: 'Traefik',
url: 'Reverse Proxy SSL',
desc: "Let's Encrypt — Routage dynamique",
},
],
apps: [
{
icon: '🧾',
color: 'green',
name: 'Démat. Facturation DSI',
url: 'demat-facturation.recette.santinova-soft.org',
href: 'https://demat-facturation.recette.santinova-soft.org',
},
{
icon: '👁️',
color: 'indigo',
name: 'Veille Réglementaire',
url: 'veille.recette.santinova-soft.org',
href: 'https://veille.recette.santinova-soft.org',
},
{
icon: '🗺️',
color: 'cyan',
name: 'SONUM',
url: 'sonum.recette.santinova-soft.org',
desc: 'Cartographie solutions numériques FEHAP',
href: 'https://sonum.recette.santinova-soft.org',
},
{
icon: '👤',
color: 'pink',
name: 'Itinova Contacts',
url: 'contacts.recette.santinova-soft.org',
href: 'https://contacts.recette.santinova-soft.org',
},
{
icon: '🚗',
color: 'yellow',
name: 'Flotte Véhicules',
url: 'flotte.recette.santinova-soft.org',
href: 'https://flotte.recette.santinova-soft.org',
},
{
icon: '🎧',
color: 'orange',
name: 'Podcasts Itinova',
url: 'podcasts.recette.santinova-soft.org',
href: 'https://podcasts.recette.santinova-soft.org',
},
],
};
const PRODUCTION = {
label: 'PRODUCTION',
color: 'emerald',
ip: '180.149.196.138',
os: 'Debian GNU/Linux 12',
cpu: '8 vCores',
ram: '16 Go',
disk: '246 Go',
services: [
{
icon: GitBranch,
color: 'purple',
name: 'Gitea',
url: 'git.santinova-soft.org',
desc: 'Dépôt Git principal',
href: 'https://git.santinova-soft.org',
},
{
icon: LayoutDashboard,
color: 'blue',
name: 'Dashboard',
url: 'dashboard.santinova-soft.org',
desc: 'Monitoring temps réel',
href: 'https://dashboard.santinova-soft.org',
},
{
icon: Globe,
color: 'teal',
name: 'Portail Applicatif',
url: 'portail.santinova-soft.org',
desc: 'Vitrine applicative production',
href: 'https://portail.santinova-soft.org',
},
{
icon: Shield,
color: 'gray',
name: 'Traefik',
url: 'Reverse Proxy SSL',
desc: "Let's Encrypt — Routage dynamique",
},
],
};
const CICD_STEPS = [
{ icon: GitBranch, label: 'Push code', desc: 'Développeur pousse sur Gitea' },
{ icon: Zap, label: 'Webhook', desc: 'Gitea déclenche le webhook' },
{ icon: Package, label: 'docker compose build', desc: 'Build de la nouvelle image' },
{ icon: Server, label: 'docker compose up -d', desc: 'Redémarrage du conteneur' },
{ icon: CheckCircle, label: 'Health check', desc: 'Vérification de disponibilité' },
];
/* ─────────────────────────────────────────────
Helpers de couleur
───────────────────────────────────────────── */
const colorMap = {
purple: 'border-purple-500/40 bg-purple-500/10 text-purple-300',
blue: 'border-blue-500/40 bg-blue-500/10 text-blue-300',
teal: 'border-teal-500/40 bg-teal-500/10 text-teal-300',
gray: 'border-gray-500/40 bg-gray-500/10 text-gray-300',
green: 'border-green-500/40 bg-green-500/10 text-green-300',
indigo: 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300',
cyan: 'border-cyan-500/40 bg-cyan-500/10 text-cyan-300',
pink: 'border-pink-500/40 bg-pink-500/10 text-pink-300',
yellow: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-300',
orange: 'border-orange-500/40 bg-orange-500/10 text-orange-300',
};
/* ─────────────────────────────────────────────
Composants
───────────────────────────────────────────── */
function ServerBadge({ env }) {
const isAmber = env.color === 'amber';
const borderColor = isAmber ? 'border-amber-500/30' : 'border-emerald-500/30';
const bgColor = isAmber ? 'bg-amber-500/5' : 'bg-emerald-500/5';
return (
<div className={`flex flex-wrap gap-4 p-3 rounded-xl border ${borderColor} ${bgColor} mb-5`}>
{[
{ icon: Server, label: env.ip },
{ icon: Cpu, label: env.cpu },
{ icon: Activity, label: env.ram + ' RAM' },
{ icon: HardDrive, label: env.disk + ' Disque' },
{ icon: Network, label: env.os },
].map(({ icon: Icon, label }) => (
<div key={label} className="flex items-center gap-1.5 text-xs text-gray-400">
<Icon className="w-3.5 h-3.5 text-gray-500" />
<span>{label}</span>
</div>
))}
</div>
);
}
function ServiceCard({ item, withDb = false }) {
const cls = colorMap[item.color] || colorMap.gray;
const Icon = item.icon;
return (
<div className={`flex items-start gap-3 p-3 rounded-xl border ${cls} transition-all hover:scale-[1.01]`}>
<div className="mt-0.5 flex-shrink-0">
{typeof Icon === 'string' ? (
<span className="text-xl">{Icon}</span>
) : (
<Icon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-white">{item.name}</span>
{item.href && (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-300 transition-colors"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<p className="text-xs text-gray-400 truncate">{item.url}</p>
{item.desc && <p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>}
</div>
{withDb && (
<div className="flex items-center gap-1 text-xs text-gray-600 flex-shrink-0">
<Database className="w-3 h-3" />
<span>MySQL</span>
</div>
)}
</div>
);
}
function SectionTitle({ children, color = 'amber' }) {
const cls = color === 'amber' ? 'text-amber-400' : 'text-emerald-400';
return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>;
}
/* ─────────────────────────────────────────────
Page principale
───────────────────────────────────────────── */
export default function DocumentationPage() {
const [schemaOpen, setSchemaOpen] = useState(true);
return (
<div className="space-y-8 pb-12">
{/* ── En-tête ── */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<BookOpen className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Documentation Infrastructure</h1>
<p className="text-sm text-gray-400">Architecture de déploiement Santinova Recette &amp; Production</p>
</div>
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<Clock className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span>
</div>
</div>
{/* ── Schéma d'architecture ── */}
<div className="rounded-2xl border border-dark-700 bg-dark-900 overflow-hidden">
<button
onClick={() => setSchemaOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-dark-800 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
<Network className="w-4 h-4 text-blue-400" />
</div>
<span className="font-semibold text-white">Schéma d'Architecture</span>
</div>
{schemaOpen ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{schemaOpen && (
<div className="px-6 pb-6">
<img
src="/infra_schema_v2.png"
alt="Schéma d'architecture infrastructure Santinova"
className="w-full rounded-xl border border-dark-700 shadow-2xl"
/>
</div>
)}
</div>
{/* ── Grille Recette / CI-CD / Production ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">
{/* ── RECETTE ── */}
<div className="rounded-2xl border border-amber-500/30 bg-dark-900 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-lg shadow-amber-400/50" />
<h2 className="text-xl font-bold text-amber-400 tracking-wide">RECETTE</h2>
<span className="ml-auto text-xs text-gray-500 font-mono">78.138.58.109</span>
</div>
<ServerBadge env={RECETTE} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Infra de base */}
<div>
<SectionTitle color="amber">Infrastructure de base</SectionTitle>
<div className="space-y-2">
{RECETTE.infra.map((item) => (
<ServiceCard key={item.name} item={item} />
))}
</div>
</div>
{/* Applications */}
<div>
<SectionTitle color="amber">Applications déployées</SectionTitle>
<div className="space-y-2">
{RECETTE.apps.map((item) => (
<ServiceCard key={item.name} item={item} withDb />
))}
</div>
</div>
</div>
</div>
{/* ── CI/CD Pipeline ── */}
<div className="flex flex-col items-center justify-center gap-3 px-4 xl:px-2 py-6">
<span className="text-xs font-bold uppercase tracking-widest text-blue-400 mb-2">CI/CD</span>
{CICD_STEPS.map((step, i) => {
const Icon = step.icon;
return (
<React.Fragment key={step.label}>
<div className="flex flex-col items-center gap-1 group">
<div className="w-10 h-10 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center group-hover:bg-blue-600/30 transition-colors">
<Icon className="w-5 h-5 text-blue-400" />
</div>
<span className="text-xs text-gray-400 text-center max-w-[80px] leading-tight">{step.label}</span>
</div>
{i < CICD_STEPS.length - 1 && (
<div className="w-px h-4 bg-gradient-to-b from-blue-500/50 to-blue-500/10" />
)}
</React.Fragment>
);
})}
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-xl bg-blue-600/10 border border-blue-500/20">
<ArrowRight className="w-4 h-4 text-blue-400" />
<span className="text-xs text-blue-300 font-semibold whitespace-nowrap">Promotion → PROD</span>
</div>
</div>
{/* ── PRODUCTION ── */}
<div className="rounded-2xl border border-emerald-500/30 bg-dark-900 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 rounded-full bg-emerald-400 shadow-lg shadow-emerald-400/50" />
<h2 className="text-xl font-bold text-emerald-400 tracking-wide">PRODUCTION</h2>
<span className="ml-auto text-xs text-gray-500 font-mono">180.149.196.138</span>
</div>
<ServerBadge env={PRODUCTION} />
<SectionTitle color="emerald">Services actifs</SectionTitle>
<div className="space-y-2 mb-4">
{PRODUCTION.services.map((item) => (
<ServiceCard key={item.name} item={item} />
))}
</div>
{/* Carte "Applications à venir" */}
<div className="flex items-center gap-3 p-3 rounded-xl border border-dashed border-gray-600/40 bg-gray-500/5">
<Package className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm font-semibold text-gray-400">Applications à venir</p>
<p className="text-xs text-gray-600">Déploiement progressif via CI/CD</p>
</div>
</div>
{/* Badge metrics-collector */}
<div className="mt-4 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/20">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">metrics-collector · systemd · actif</span>
</div>
</div>
</div>
{/* ── Légende technologique ── */}
<div className="rounded-2xl border border-dark-700 bg-dark-900 p-6">
<h3 className="text-sm font-bold text-white mb-4">Stack Technologique</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ emoji: '🐳', label: 'Docker', desc: 'Conteneurisation' },
{ emoji: '🔒', label: 'HTTPS', desc: "Let's Encrypt SSL" },
{ emoji: '🗄', label: 'MySQL 8.0', desc: 'Base de données' },
{ emoji: '🔀', label: 'Traefik', desc: 'Reverse Proxy' },
].map((t) => (
<div
key={t.label}
className="flex items-center gap-3 p-3 rounded-xl bg-dark-800 border border-dark-700"
>
<span className="text-2xl">{t.emoji}</span>
<div>
<p className="text-sm font-semibold text-white">{t.label}</p>
<p className="text-xs text-gray-500">{t.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -9,8 +9,14 @@ import {
Clock,
User,
Code,
CheckCircle2,
XCircle,
Loader2,
TrendingDown,
TrendingUp,
Minus,
} from 'lucide-react';
import { getGiteaRepos, getGiteaCommits } from '../utils/api';
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
@@ -27,11 +33,30 @@ function formatSize(kb) {
return `${(kb / 1024).toFixed(1)} MB`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return 'En attente';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return minutes > 0 ? `${minutes} min ${remainingSeconds.toString().padStart(2, '0')} s` : `${remainingSeconds} s`;
}
function CiStatus({ run }) {
if (run.status !== 'completed') {
return <span className="text-amber-300 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> En cours</span>;
}
if (run.conclusion === 'success') {
return <span className="text-emerald-300 flex items-center gap-1"><CheckCircle2 className="w-3 h-3" /> Réussi</span>;
}
return <span className="text-red-300 flex items-center gap-1"><XCircle className="w-3 h-3" /> {run.conclusion || 'Échec'}</span>;
}
export default function GiteaPage() {
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedRepo, setSelectedRepo] = useState(null);
const [commits, setCommits] = useState([]);
const [workflowRuns, setWorkflowRuns] = useState([]);
const [workflowSummary, setWorkflowSummary] = useState(null);
const [loadingCommits, setLoadingCommits] = useState(false);
const fetchRepos = async () => {
@@ -51,11 +76,19 @@ export default function GiteaPage() {
setSelectedRepo(fullName);
try {
const [owner, repo] = fullName.split('/');
const res = await getGiteaCommits(owner, repo);
setCommits(res.data);
const [commitsResponse, runsResponse, summaryResponse] = await Promise.all([
getGiteaCommits(owner, repo),
getGiteaWorkflowRuns(owner, repo),
getGiteaWorkflowSummary(owner, repo),
]);
setCommits(commitsResponse.data);
setWorkflowRuns(runsResponse.data);
setWorkflowSummary(summaryResponse.data);
} catch (err) {
console.error('Erreur chargement commits:', err);
setCommits([]);
setWorkflowRuns([]);
setWorkflowSummary(null);
} finally {
setLoadingCommits(false);
}
@@ -77,12 +110,12 @@ export default function GiteaPage() {
<p className="text-gray-400 mt-1">
Explorez les dépôts sur{' '}
<a
href="https://git.santinova-soft.org"
href="https://git.recette.santinova-soft.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary-400 hover:text-primary-300"
>
git.santinova-soft.org
git.recette.santinova-soft.org
</a>
</p>
</div>
@@ -191,6 +224,48 @@ export default function GiteaPage() {
</div>
) : (
<div className="space-y-2">
<div className="card p-3">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">Dernières validations CI</p>
{workflowRuns.length === 0 ? (
<p className="text-sm text-gray-500">Aucune validation CI trouvée pour ce dépôt.</p>
) : (
<>
{workflowSummary?.sampleSize > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3 text-xs">
<div className="rounded bg-dark-700/70 p-2">
<p className="text-gray-500">Succès</p>
<p className="font-semibold text-emerald-300">{workflowSummary.successRate}%</p>
</div>
<div className="rounded bg-dark-700/70 p-2">
<p className="text-gray-500">Médiane</p>
<p className="font-semibold text-gray-200">{formatDuration(workflowSummary.medianDurationSeconds)}</p>
</div>
<div className="rounded bg-dark-700/70 p-2">
<p className="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) => (
<div key={commit.sha || idx} className="card p-3">
<div className="flex items-start gap-3">

View File

@@ -0,0 +1,458 @@
import React, { useState, useEffect } from 'react';
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react';
import api from '../utils/api';
const APPS_CONFIG = [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
repoName: 'itinova-contacts',
urlRecette: 'https://contacts.recette.santinova-soft.org',
urlProd: 'https://contacts.santinova-soft.org',
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
repoName: 'itinova-podcasts',
urlRecette: 'https://podcasts.recette.santinova-soft.org',
urlProd: 'https://podcasts.santinova-soft.org',
},
{
id: 'veille-reglementaire',
name: 'Veille Réglementaire',
repoName: 'veille-reglementaire',
urlRecette: 'https://veille.recette.santinova-soft.org',
urlProd: 'https://veille.santinova-soft.org',
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
repoName: 'itinova-vehicle-exchange',
urlRecette: 'https://flotte.recette.santinova-soft.org',
urlProd: 'https://flotte.santinova-soft.org',
},
{
id: 'sonum',
name: 'SONUM',
repoName: 'sonum',
urlRecette: 'https://sonum.recette.santinova-soft.org',
urlProd: 'https://sonum.santinova-soft.org',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
repoName: 'demat-facturation-dsi',
urlRecette: 'https://demat-facturation.recette.santinova-soft.org',
urlProd: 'https://demat-facturation.santinova-soft.org',
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
repoName: 'facturation-santinova',
urlRecette: 'https://facturation.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
repoName: 'formation-manager-itinova',
urlRecette: 'https://formation.recette.santinova-soft.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',
name: 'Portail Applicatif',
repoName: 'portail-santinova',
urlRecette: 'https://portail.recette.santinova-soft.org',
urlProd: 'https://portail.santinova-soft.org',
},
{
id: 'manus-dashboard',
name: 'Dashboard',
repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.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 }) {
if (!version) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
<Tag className="w-3 h-3" />
Chargement...
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500 italic">
<AlertCircle className="w-3 h-3" />
Non déployé
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-blue-900/40 text-blue-300 border border-blue-700/40">
<Tag className="w-3 h-3" />
{version}
</span>
);
}
function RepoBadge({ present, url, label }) {
if (!present) {
return (
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-gray-500 text-xs">Absent</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-400 hover:text-primary-300 hover:underline flex items-center gap-1 truncate max-w-[180px]"
title={url}
>
{label}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
</div>
);
}
export default function InventairePage() {
const [inventory, setInventory] = useState([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(null);
const fetchInventory = async () => {
setLoading(true);
try {
const res = await api.get('/inventory');
setInventory(res.data);
setLastUpdate(new Date());
} catch (err) {
console.error('Erreur chargement inventaire:', err);
// Fallback : construire l'inventaire depuis la config statique
const fallback = APPS_CONFIG.map((app) => ({
...app,
repoRecette: { present: false, url: null, version: null },
repoProd: { present: false, url: null, version: null },
}));
setInventory(fallback);
} finally {
setLoading(false);
}
};
// Auto-refresh toutes les 5 minutes
useEffect(() => {
fetchInventory();
const interval = setInterval(fetchInventory, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
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">
<Table className="w-7 h-7 text-emerald-400" />
Inventaire des Applications
</h2>
<p className="text-gray-400 mt-1">
État des dépôts Git et versions déployées par environnement
</p>
</div>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-gray-500">
Mis à jour : {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
<button
onClick={fetchInventory}
disabled={loading}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</button>
</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 */}
<div className="flex items-center gap-6 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500" />
Dépôt présent
</div>
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500" />
Dépôt absent
</div>
<div className="flex items-center gap-1.5">
<Tag className="w-4 h-4 text-blue-400" />
Version (dernier commit)
</div>
</div>
{/* Tableau */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-700">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
Application
</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}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Recette
</div>
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-green-400 uppercase tracking-wider" colSpan={2}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Production
</div>
</th>
</tr>
<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="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">Dépôt Git</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{loading && inventory.length === 0
? APPS_CONFIG.map((app) => (
<tr key={app.id} className="hover:bg-dark-800/30 transition-colors">
<td className="px-4 py-3">
<span className="text-sm font-medium text-white">{app.name}</span>
</td>
{[...Array(5)].map((_, i) => (
<td key={i} className="px-3 py-3 text-center">
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
</td>
))}
</tr>
))
: inventory.map((app) => {
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return (
<tr key={app.id} className={`hover:bg-dark-800/30 transition-colors${isInfra ? ' bg-amber-950/10 border-l-2 border-amber-500/40' : ''}`}>
<td className="px-4 py-3">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isInfra ? 'text-amber-300' : 'text-white'}`}>{app.name}</span>
{isInfra && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 border border-amber-500/25 font-semibold">Infra</span>
)}
</div>
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
</div>
</td>
{/* Synchronisation */}
<td className="px-3 py-3 text-center">
<SyncBadge
versionRecette={app.repoRecette?.version}
versionProd={app.repoProd?.version}
/>
</td>
{/* Recette - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoRecette?.present}
url={app.repoRecette?.url}
label={`git.recette/…/${app.repoName}`}
/>
</td>
{/* Recette - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoRecette?.version}
loading={loading}
/>
</td>
{/* Prod - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoProd?.present}
url={app.repoProd?.url}
label={`git.santinova/…/${app.repoName}`}
/>
</td>
{/* Prod - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoProd?.version}
loading={loading}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -34,7 +34,9 @@ export default function LoginPage({ onLogin }) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-600 mb-4">
<Server className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white">Manus Dashboard</h1>
<h1 className="text-2xl font-bold text-white">
Dashboard {window.location.hostname.includes('recette') ? 'Recette' : 'Production'}
</h1>
<p className="text-gray-400 mt-2">Gestion des applications</p>
</div>
@@ -130,7 +132,7 @@ export default function LoginPage({ onLogin }) {
</div>
<p className="text-center text-gray-600 text-sm mt-6">
Santinova Soft &mdash; Serveur de recette
Santinova Soft &mdash; Serveur de production
</p>
</div>
</div>

View File

@@ -0,0 +1,449 @@
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>
);
}

View File

@@ -59,6 +59,10 @@ export const getDeployments = (appId) =>
export const getGiteaRepos = () => api.get('/gitea/repos');
export const getGiteaCommits = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/commits`);
export const getGiteaWorkflowRuns = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
export const getGiteaWorkflowSummary = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
// Docker
export const getContainers = () => api.get('/docker/containers');