diff --git a/src/backend/src/gitea.js b/src/backend/src/gitea.js
index 391f19f..17f8060 100644
--- a/src/backend/src/gitea.js
+++ b/src/backend/src/gitea.js
@@ -114,6 +114,53 @@ async function getWorkflowRunForCommit(owner, repo, commitHash) {
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)
*/
@@ -158,8 +205,10 @@ module.exports = {
getCommits,
getBranches,
getWorkflowRuns,
+ getWorkflowRunSummary,
getWorkflowRunForCommit,
normalizeWorkflowRun,
+ calculateCiSummary,
listRepos,
createRepo,
giteaClient,
diff --git a/src/backend/src/routes.js b/src/backend/src/routes.js
index 841f7ca..8d28248 100644
--- a/src/backend/src/routes.js
+++ b/src/backend/src/routes.js
@@ -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, getWorkflowRuns, 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');
@@ -409,6 +409,15 @@ router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req,
}
});
+// 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) => {
diff --git a/src/backend/test/gitea.test.js b/src/backend/test/gitea.test.js
index fd047c6..879d7a6 100644
--- a/src/backend/test/gitea.test.js
+++ b/src/backend/test/gitea.test.js
@@ -1,5 +1,5 @@
const assert = require('node:assert/strict');
-const { normalizeWorkflowRun } = require('../src/gitea');
+const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
const run = normalizeWorkflowRun({
id: 42,
@@ -14,4 +14,14 @@ const run = normalizeWorkflowRun({
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');
diff --git a/src/frontend/src/pages/GiteaPage.jsx b/src/frontend/src/pages/GiteaPage.jsx
index e36f657..7ada476 100644
--- a/src/frontend/src/pages/GiteaPage.jsx
+++ b/src/frontend/src/pages/GiteaPage.jsx
@@ -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
Dernières validations CI
+ {workflowRuns.length === 0 ? ( +Aucune validation CI trouvée pour ce dépôt.
+ ) : ( + <> + {workflowSummary?.sampleSize > 0 && ( +Succès
+{workflowSummary.successRate}%
+Médiane
+{formatDuration(workflowSummary.medianDurationSeconds)}
+Tendance
+
+ {workflowSummary.trend === 'faster' ?
{run.name}
+{run.commit?.slice(0, 7) || 'Commit inconnu'} · {formatDate(run.createdAt)}
+