Compare commits
8 Commits
fe0a81fc98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79240dd914 | ||
|
|
62169d655a | ||
|
|
b436176446 | ||
|
|
a455d3dd13 | ||
|
|
d9610d2be4 | ||
|
|
c3bf677972 | ||
|
|
40ac705398 | ||
|
|
255f54cf27 |
27
.gitea/workflows/validate.yml
Normal file
27
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Validation Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Tests backend et build frontend
|
||||
runs-on: ci-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Installer et tester le backend
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
|
||||
- name: Installer et construire le frontend
|
||||
working-directory: src/frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
5
app.json
5
app.json
@@ -6,5 +6,8 @@
|
||||
"containerName": "manus-dashboard",
|
||||
"image": "images/dashboard.png",
|
||||
"giteaRepo": "manus-dashboard",
|
||||
"giteaOwner": "manus-admin"
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
|
||||
9
ops/Dockerfile.gitea-runner
Normal file
9
ops/Dockerfile.gitea-runner
Normal file
@@ -0,0 +1,9 @@
|
||||
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
|
||||
FROM node:22-alpine
|
||||
|
||||
# actions/cache s’appuie sur GNU tar pour archiver le store pnpm.
|
||||
RUN apk add --no-cache bash git tar \
|
||||
&& tar --version | grep -q 'GNU tar' \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@10.4.1 --activate \
|
||||
&& pnpm --version
|
||||
@@ -11,3 +11,7 @@ Après mise à jour Git, installer ou actualiser le service avec :
|
||||
```bash
|
||||
sudo ./ops/install-healthcheck.sh
|
||||
```
|
||||
|
||||
## Runner CI de production
|
||||
|
||||
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise l’image locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.
|
||||
|
||||
0
ops/deploy-dashboard-from-host.sh
Normal file → Executable file
0
ops/deploy-dashboard-from-host.sh
Normal file → Executable file
17
ops/gitea-act-runner-config.yaml
Normal file
17
ops/gitea-act-runner-config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
capacity: 1
|
||||
envs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
# Cache Gitea Actions persistant, accessible depuis les jobs du réseau Docker web.
|
||||
cache:
|
||||
enabled: true
|
||||
dir: /opt/manus-deploy/gitea-runner/cache
|
||||
host: 172.18.0.1
|
||||
port: 18088
|
||||
|
||||
container:
|
||||
network: web
|
||||
17
ops/gitea-act-runner.service
Normal file
17
ops/gitea-act-runner.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Runner Gitea Actions de production
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/manus-deploy/gitea-runner
|
||||
Environment=DOCKER_HOST=unix:///var/run/docker.sock
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /opt/manus-deploy/gitea-runner/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
0
ops/healthcheck-apps.sh
Normal file → Executable file
0
ops/healthcheck-apps.sh
Normal file → Executable file
38
ops/install-gitea-act-runner.sh
Executable file
38
ops/install-gitea-act-runner.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
|
||||
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
|
||||
readonly GITEA_CONTAINER="gitea"
|
||||
readonly GITEA_URL="https://git.santinova-soft.org"
|
||||
readonly RUNNER_NAME="production-docker-runner"
|
||||
readonly RUNNER_IMAGE="gitea-runner-node:22"
|
||||
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
|
||||
readonly LABEL_FILE="${RUNNER_DIR}/labels"
|
||||
|
||||
install -d -m 0750 "${RUNNER_DIR}" "${RUNNER_DIR}/cache"
|
||||
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
|
||||
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
|
||||
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
|
||||
systemctl stop gitea-act-runner.service 2>/dev/null || true
|
||||
rm -f "${RUNNER_DIR}/.runner"
|
||||
token="$(docker exec -u git "${GITEA_CONTAINER}" gitea actions generate-runner-token)"
|
||||
(
|
||||
cd "${RUNNER_DIR}"
|
||||
/usr/local/bin/act_runner register --no-interactive \
|
||||
--instance "${GITEA_URL}" \
|
||||
--token "${token}" \
|
||||
--name "${RUNNER_NAME}" \
|
||||
--labels "${RUNNER_LABELS}" \
|
||||
--config "${RUNNER_DIR}/config.yaml"
|
||||
)
|
||||
printf '%s\n' "${RUNNER_LABELS}" > "${LABEL_FILE}"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-act-runner.service
|
||||
systemctl is-active gitea-act-runner.service
|
||||
0
ops/install-healthcheck.sh
Normal file → Executable file
0
ops/install-healthcheck.sh
Normal file → Executable file
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.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",
|
||||
|
||||
@@ -44,6 +44,12 @@ function normalizeManifest(manifest, directory, environment) {
|
||||
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,
|
||||
@@ -51,6 +57,7 @@ function normalizeManifest(manifest, directory, environment) {
|
||||
directory,
|
||||
containerName: manifest.containerName || manifest.id,
|
||||
healthCheckUrl: manifest.urls[environment],
|
||||
ci: { required: manifest.ci?.required === true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,14 +138,23 @@ function writeAppManifest({ appsBasePath, environment, app }) {
|
||||
}
|
||||
|
||||
function findAppDirectoryByRepo(config, repositoryName) {
|
||||
const { apps } = refreshApps(config);
|
||||
const app = apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === 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,
|
||||
};
|
||||
|
||||
@@ -70,6 +70,97 @@ async function getBranches(owner, repo) {
|
||||
}
|
||||
}
|
||||
|
||||
function toDurationSeconds(startedAt, completedAt) {
|
||||
if (!startedAt || !completedAt) return null;
|
||||
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
|
||||
}
|
||||
|
||||
/** Normalise les exécutions CI pour le webhook et le dashboard. */
|
||||
function normalizeWorkflowRun(run) {
|
||||
// Gitea 1.25 renvoie started_at/completed_at (et non les champs GitHub).
|
||||
// Les aliases conservent la compatibilité avec d’éventuelles versions futures.
|
||||
const startedAt = run.started_at || run.run_started_at || null;
|
||||
const completedAt = run.completed_at || run.run_completed_at || run.updated_at || null;
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name || run.workflow_id || 'Validation CI',
|
||||
status: run.status || 'unknown',
|
||||
conclusion: run.conclusion || null,
|
||||
event: run.event || null,
|
||||
commit: run.head_sha || null,
|
||||
createdAt: run.created_at || null,
|
||||
startedAt,
|
||||
completedAt,
|
||||
durationSeconds: toDurationSeconds(startedAt, completedAt),
|
||||
url: run.html_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkflowRuns(owner, repo, limit = 10) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
|
||||
params: { limit, page: 1 },
|
||||
});
|
||||
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkflowRunForCommit(owner, repo, commitHash) {
|
||||
const runs = await getWorkflowRuns(owner, repo, 30);
|
||||
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
|
||||
}
|
||||
|
||||
/** Retourne la médiane d'une série numérique non vide. */
|
||||
function median(values) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une synthèse déterministe sur les derniers runs CI.
|
||||
* Les comparaisons ne sont calculées qu’avec deux fenêtres d’au moins deux runs
|
||||
* pour ne pas transformer une variation isolée en tendance.
|
||||
*/
|
||||
function calculateCiSummary(runs) {
|
||||
const completed = runs.filter((run) => run.status === 'completed');
|
||||
const successful = completed.filter((run) => run.conclusion === 'success');
|
||||
const durations = completed.map((run) => run.durationSeconds).filter(Number.isFinite);
|
||||
const windowSize = Math.floor(durations.length / 2);
|
||||
const recent = windowSize >= 2 ? durations.slice(0, windowSize) : [];
|
||||
const previous = windowSize >= 2 ? durations.slice(windowSize, windowSize * 2) : [];
|
||||
const recentMedian = median(recent);
|
||||
const previousMedian = median(previous);
|
||||
const changePercent = previousMedian && recentMedian !== null
|
||||
? Math.round(((recentMedian - previousMedian) / previousMedian) * 100)
|
||||
: null;
|
||||
const trend = changePercent === null ? 'unknown'
|
||||
: changePercent <= -10 ? 'faster'
|
||||
: changePercent >= 10 ? 'slower'
|
||||
: 'stable';
|
||||
|
||||
return {
|
||||
sampleSize: completed.length,
|
||||
successful: successful.length,
|
||||
failed: completed.length - successful.length,
|
||||
successRate: completed.length ? Math.round((successful.length / completed.length) * 100) : null,
|
||||
medianDurationSeconds: median(durations),
|
||||
recentMedianDurationSeconds: recentMedian,
|
||||
previousMedianDurationSeconds: previousMedian,
|
||||
trend,
|
||||
changePercent,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkflowRunSummary(owner, repo) {
|
||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
@@ -113,6 +204,11 @@ module.exports = {
|
||||
getRepo,
|
||||
getCommits,
|
||||
getBranches,
|
||||
getWorkflowRuns,
|
||||
getWorkflowRunSummary,
|
||||
getWorkflowRunForCommit,
|
||||
normalizeWorkflowRun,
|
||||
calculateCiSummary,
|
||||
listRepos,
|
||||
createRepo,
|
||||
giteaClient,
|
||||
|
||||
@@ -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');
|
||||
@@ -399,6 +399,25 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Dernières validations CI avec statut et durée, utilisées par l'écran Gitea.
|
||||
router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const runs = await getWorkflowRuns(req.params.owner, req.params.repo, 10);
|
||||
res.json(runs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Synthèse historique : taux de succès, médiane et tendance des validations CI.
|
||||
router.get('/gitea/repos/:owner/:repo/actions/summary', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
res.json(await getWorkflowRunSummary(req.params.owner, req.params.repo));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
||||
router.get('/public/status', async (req, res) => {
|
||||
|
||||
@@ -9,7 +9,8 @@ const { execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const { findAppDirectoryByRepo } = require('./app-registry');
|
||||
const { findAppByRepo } = require('./app-registry');
|
||||
const { getWorkflowRunForCommit } = require('./gitea');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,6 +20,8 @@ const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const DASHBOARD_APP_ID = 'manus-dashboard';
|
||||
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
|
||||
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||||
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
|
||||
|
||||
// Déploiements en cours (évite les doubles déclenchements)
|
||||
const deployingApps = new Set();
|
||||
@@ -44,6 +47,54 @@ function requestDashboardHostDeployment({ branch, commitHash, committer }) {
|
||||
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
|
||||
}
|
||||
|
||||
function shouldRequireCi(app) {
|
||||
return app?.ci?.required === true;
|
||||
}
|
||||
|
||||
function saveDeploymentStatus(appName, status) {
|
||||
try {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify(status, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[Webhook] Erreur sauvegarde statut:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delay) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une application déclarant ci.required ne peut être déployée que si le commit
|
||||
* reçu dispose d’un run Gitea terminé avec succès.
|
||||
*/
|
||||
async function waitForSuccessfulCi(app, commitHash) {
|
||||
const owner = app.giteaOwner || 'manus-admin';
|
||||
const repo = app.giteaRepo || app.id;
|
||||
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
|
||||
let lastRun = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
|
||||
if (lastRun?.status === 'completed') {
|
||||
return {
|
||||
allowed: lastRun.conclusion === 'success',
|
||||
run: lastRun,
|
||||
reason: lastRun.conclusion === 'success'
|
||||
? null
|
||||
: `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
|
||||
};
|
||||
}
|
||||
await wait(CI_GATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
run: lastRun,
|
||||
reason: lastRun ? 'La CI n’a pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
|
||||
};
|
||||
}
|
||||
|
||||
function verifyGiteaSignature(req) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
|
||||
@@ -93,16 +144,41 @@ 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({
|
||||
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); }
|
||||
saveDeploymentStatus(appName, {
|
||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
router.post('/gitea', (req, res) => {
|
||||
if (!verifyGiteaSignature(req)) {
|
||||
@@ -122,24 +198,20 @@ router.post('/gitea', (req, res) => {
|
||||
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
|
||||
|
||||
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
|
||||
const appName = findAppDirectoryByRepo(config, repoName);
|
||||
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
|
||||
const app = findAppByRepo(config, repoName);
|
||||
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
|
||||
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
|
||||
|
||||
if (isHostManagedDeployment(appName)) {
|
||||
requestDashboardHostDeployment({ branch, commitHash, committer });
|
||||
return res.status(202).json({
|
||||
message: 'Redéploiement du dashboard confié au service hôte',
|
||||
commit: commitHash,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -168,3 +240,5 @@ router.get('/status', (req, res) => {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
||||
module.exports.shouldRequireCi = shouldRequireCi;
|
||||
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;
|
||||
|
||||
27
src/backend/test/gitea.test.js
Normal file
27
src/backend/test/gitea.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
|
||||
|
||||
const run = normalizeWorkflowRun({
|
||||
id: 42,
|
||||
name: 'Validation',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
head_sha: 'abcdef123456',
|
||||
started_at: '2026-08-18T10:00:10Z',
|
||||
completed_at: '2026-08-18T10:02:15Z',
|
||||
});
|
||||
|
||||
assert.equal(run.commit, 'abcdef123456');
|
||||
assert.equal(run.durationSeconds, 125);
|
||||
assert.equal(run.conclusion, 'success');
|
||||
|
||||
const summary = calculateCiSummary([
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 90 },
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 110 },
|
||||
{ status: 'completed', conclusion: 'failure', durationSeconds: 120 },
|
||||
{ status: 'completed', conclusion: 'success', durationSeconds: 100 },
|
||||
]);
|
||||
assert.equal(summary.successRate, 75);
|
||||
assert.equal(summary.medianDurationSeconds, 105);
|
||||
assert.equal(summary.trend, 'stable');
|
||||
console.log('OK gitea');
|
||||
@@ -4,8 +4,11 @@
|
||||
* donc déléguer ce cas précis à un service systemd hôte.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const { isHostManagedDeployment } = require('../src/webhook');
|
||||
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');
|
||||
|
||||
3030
src/frontend/package-lock.json
generated
Normal file
3030
src/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Dashboard</h1>
|
||||
<p className="text-xs text-gray-500">{window.location.hostname.includes('recette') ? 'Recette' : 'Production'}</p>
|
||||
<p className="text-xs text-gray-500">Production</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,10 +147,12 @@ export default function DashboardPage({ apps }) {
|
||||
<p className="text-gray-500 text-sm">Aucune application détectée</p>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function LoginPage({ onLogin }) {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-sm mt-6">
|
||||
Santinova Soft — Serveur de recette
|
||||
Santinova Soft — Serveur de production
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function TerminalPage() {
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
term.writeln('\x1b[1;34m╔══════════════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[1;34m║ Terminal SSH — Dashboard Recette Santinova ║\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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user