From a455d3dd136661eaf09dd6eb576b9228d14e213d Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 20 Aug 2026 15:50:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(ci):=20afficher=20tendances=20et=20m=C3=A9?= =?UTF-8?q?dianes=20des=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/src/gitea.js | 49 +++++++++++++++++ src/backend/src/routes.js | 11 +++- src/backend/test/gitea.test.js | 12 ++++- src/frontend/src/pages/GiteaPage.jsx | 81 ++++++++++++++++++++++++++-- src/frontend/src/utils/api.js | 4 ++ 5 files changed, 152 insertions(+), 5 deletions(-) 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 En cours; + } + if (run.conclusion === 'success') { + return Réussi; + } + return {run.conclusion || 'Échec'}; +} + 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() { ) : (
+
+

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' ? : workflowSummary.trend === 'slower' ? : } + {workflowSummary.changePercent === null ? 'N/A' : `${Math.abs(workflowSummary.changePercent)}%`} +

+
+
+ )} +
+ {workflowRuns.slice(0, 5).map((run) => ( +
+
+

{run.name}

+

{run.commit?.slice(0, 7) || 'Commit inconnu'} · {formatDate(run.createdAt)}

+
+
+ + {formatDuration(run.durationSeconds)} +
+
+ ))} +
+ + )} +
{commits.map((commit, idx) => (
diff --git a/src/frontend/src/utils/api.js b/src/frontend/src/utils/api.js index 322ad77..482bf54 100644 --- a/src/frontend/src/utils/api.js +++ b/src/frontend/src/utils/api.js @@ -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');