Compare commits

...

6 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
9 changed files with 173 additions and 17 deletions

View File

@@ -78,8 +78,10 @@ function toDurationSeconds(startedAt, completedAt) {
/** Normalise les exécutions CI pour le webhook et le dashboard. */
function normalizeWorkflowRun(run) {
const startedAt = run.run_started_at || run.started_at || null;
const completedAt = run.updated_at || (run.status === 'completed' ? run.created_at : null);
// 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',
@@ -112,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 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)
*/
@@ -156,8 +205,10 @@ module.exports = {
getCommits,
getBranches,
getWorkflowRuns,
getWorkflowRunSummary,
getWorkflowRunForCommit,
normalizeWorkflowRun,
calculateCiSummary,
listRepos,
createRepo,
giteaClient,

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, 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) => {

View File

@@ -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,
@@ -7,12 +7,21 @@ const run = normalizeWorkflowRun({
status: 'completed',
conclusion: 'success',
head_sha: 'abcdef123456',
created_at: '2026-08-18T10:00:00Z',
run_started_at: '2026-08-18T10:00:10Z',
updated_at: '2026-08-18T10:02:15Z',
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

@@ -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>

View File

@@ -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

@@ -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">

View File

@@ -132,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

@@ -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');

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');