feat(ci): afficher tendances et médianes des workflows
This commit is contained in:
@@ -111,6 +111,50 @@ async function getWorkflowRunForCommit(owner, repo, commitHash) {
|
|||||||
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrège les derniers runs CI sans conclure à une tendance avec un échantillon insuffisant.
|
||||||
|
*/
|
||||||
|
function calculateCiSummary(runs) {
|
||||||
|
const completed = runs.filter((run) => run.status === 'completed');
|
||||||
|
const successful = completed.filter((run) => run.conclusion === 'success');
|
||||||
|
const durations = completed.map((run) => run.durationSeconds).filter(Number.isFinite);
|
||||||
|
const windowSize = Math.floor(durations.length / 2);
|
||||||
|
const recent = windowSize >= 2 ? durations.slice(0, windowSize) : [];
|
||||||
|
const previous = windowSize >= 2 ? durations.slice(windowSize, windowSize * 2) : [];
|
||||||
|
const recentMedian = median(recent);
|
||||||
|
const previousMedian = median(previous);
|
||||||
|
const changePercent = previousMedian && recentMedian !== null
|
||||||
|
? Math.round(((recentMedian - previousMedian) / previousMedian) * 100)
|
||||||
|
: null;
|
||||||
|
const trend = changePercent === null ? 'unknown'
|
||||||
|
: changePercent <= -10 ? 'faster'
|
||||||
|
: changePercent >= 10 ? 'slower'
|
||||||
|
: 'stable';
|
||||||
|
return {
|
||||||
|
sampleSize: completed.length,
|
||||||
|
successful: successful.length,
|
||||||
|
failed: completed.length - successful.length,
|
||||||
|
successRate: completed.length ? Math.round((successful.length / completed.length) * 100) : null,
|
||||||
|
medianDurationSeconds: median(durations),
|
||||||
|
recentMedianDurationSeconds: recentMedian,
|
||||||
|
previousMedianDurationSeconds: previousMedian,
|
||||||
|
trend,
|
||||||
|
changePercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkflowRunSummary(owner, repo) {
|
||||||
|
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||||
*/
|
*/
|
||||||
@@ -155,8 +199,10 @@ module.exports = {
|
|||||||
getCommits,
|
getCommits,
|
||||||
getBranches,
|
getBranches,
|
||||||
getWorkflowRuns,
|
getWorkflowRuns,
|
||||||
|
getWorkflowRunSummary,
|
||||||
getWorkflowRunForCommit,
|
getWorkflowRunForCommit,
|
||||||
normalizeWorkflowRun,
|
normalizeWorkflowRun,
|
||||||
|
calculateCiSummary,
|
||||||
listRepos,
|
listRepos,
|
||||||
createRepo,
|
createRepo,
|
||||||
giteaClient,
|
giteaClient,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { authenticate, authMiddleware } = require('./auth');
|
const { authenticate, authMiddleware } = require('./auth');
|
||||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, listRepos } = require('./gitea');
|
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
|
||||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
@@ -408,6 +408,14 @@ router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) ============
|
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
||||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
||||||
router.get('/public/status', async (req, res) => {
|
router.get('/public/status', async (req, res) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { normalizeWorkflowRun } = require('../src/gitea');
|
const { normalizeWorkflowRun, calculateCiSummary } = require('../src/gitea');
|
||||||
|
|
||||||
const run = normalizeWorkflowRun({
|
const run = normalizeWorkflowRun({
|
||||||
id: 42,
|
id: 42,
|
||||||
@@ -14,4 +14,14 @@ const run = normalizeWorkflowRun({
|
|||||||
assert.equal(run.commit, 'abcdef123456');
|
assert.equal(run.commit, 'abcdef123456');
|
||||||
assert.equal(run.durationSeconds, 125);
|
assert.equal(run.durationSeconds, 125);
|
||||||
assert.equal(run.conclusion, 'success');
|
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');
|
console.log('OK gitea');
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Minus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns } from '../utils/api';
|
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
@@ -49,6 +52,7 @@ export default function GiteaPage() {
|
|||||||
const [selectedRepo, setSelectedRepo] = useState(null);
|
const [selectedRepo, setSelectedRepo] = useState(null);
|
||||||
const [commits, setCommits] = useState([]);
|
const [commits, setCommits] = useState([]);
|
||||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||||
|
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||||
|
|
||||||
const fetchRepos = async () => {
|
const fetchRepos = async () => {
|
||||||
@@ -68,16 +72,19 @@ export default function GiteaPage() {
|
|||||||
setSelectedRepo(fullName);
|
setSelectedRepo(fullName);
|
||||||
try {
|
try {
|
||||||
const [owner, repo] = fullName.split('/');
|
const [owner, repo] = fullName.split('/');
|
||||||
const [commitsResponse, runsResponse] = await Promise.all([
|
const [commitsResponse, runsResponse, summaryResponse] = await Promise.all([
|
||||||
getGiteaCommits(owner, repo),
|
getGiteaCommits(owner, repo),
|
||||||
getGiteaWorkflowRuns(owner, repo),
|
getGiteaWorkflowRuns(owner, repo),
|
||||||
|
getGiteaWorkflowSummary(owner, repo),
|
||||||
]);
|
]);
|
||||||
setCommits(commitsResponse.data);
|
setCommits(commitsResponse.data);
|
||||||
setWorkflowRuns(runsResponse.data);
|
setWorkflowRuns(runsResponse.data);
|
||||||
|
setWorkflowSummary(summaryResponse.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur chargement commits:', err);
|
console.error('Erreur chargement commits:', err);
|
||||||
setCommits([]);
|
setCommits([]);
|
||||||
setWorkflowRuns([]);
|
setWorkflowRuns([]);
|
||||||
|
setWorkflowSummary(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCommits(false);
|
setLoadingCommits(false);
|
||||||
}
|
}
|
||||||
@@ -218,6 +225,14 @@ export default function GiteaPage() {
|
|||||||
{workflowRuns.length === 0 ? (
|
{workflowRuns.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500">Aucune validation CI trouvée pour ce dépôt.</p>
|
<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={`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">
|
<div className="space-y-2">
|
||||||
{workflowRuns.slice(0, 5).map((run) => (
|
{workflowRuns.slice(0, 5).map((run) => (
|
||||||
<div key={run.id} className="flex items-center justify-between gap-3 text-xs">
|
<div key={run.id} className="flex items-center justify-between gap-3 text-xs">
|
||||||
@@ -232,6 +247,7 @@ export default function GiteaPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{commits.map((commit, idx) => (
|
{commits.map((commit, idx) => (
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const getGiteaCommits = (owner, repo) =>
|
|||||||
api.get(`/gitea/repos/${owner}/${repo}/commits`);
|
api.get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||||
export const getGiteaWorkflowRuns = (owner, repo) =>
|
export const getGiteaWorkflowRuns = (owner, repo) =>
|
||||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||||
|
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||||
|
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const getContainers = () => api.get('/docker/containers');
|
export const getContainers = () => api.get('/docker/containers');
|
||||||
|
|||||||
Reference in New Issue
Block a user