Préserver le tableau de bord CI existant

This commit is contained in:
Manus Déploiement Santinova
2026-08-21 16:31:13 +00:00
parent a37e29e42a
commit 06fad063a4
6 changed files with 549 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import DockerPage from './pages/DockerPage';
import DocumentationPage from './pages/DocumentationPage'; import DocumentationPage from './pages/DocumentationPage';
import InventairePage from './pages/InventairePage'; import InventairePage from './pages/InventairePage';
import TerminalPage from './pages/TerminalPage'; import TerminalPage from './pages/TerminalPage';
import CiPage from './pages/CiPage';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import CiAlertBanner from './components/CiAlertBanner'; import CiAlertBanner from './components/CiAlertBanner';
import useWebSocket from './hooks/useWebSocket'; import useWebSocket from './hooks/useWebSocket';
@@ -116,6 +117,8 @@ export default function App() {
return <DashboardPage apps={apps} />; return <DashboardPage apps={apps} />;
case 'apps': case 'apps':
return <AppsPage apps={apps} onRefresh={fetchApps} />; return <AppsPage apps={apps} onRefresh={fetchApps} />;
case 'ci':
return <CiPage apps={apps} />;
case 'deployments': case 'deployments':
return <DeploymentsPage />; return <DeploymentsPage />;
case 'gitea': case 'gitea':

View File

@@ -15,6 +15,7 @@ import {
RotateCcw, RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import StatusBadge from './StatusBadge'; import StatusBadge from './StatusBadge';
import CiBadgeInline from './CiBadgeInline';
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api'; import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -137,6 +138,7 @@ export default function AppCard({ app, commits, onRefresh }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-white">{app.name}</h3> <h3 className="text-lg font-semibold text-white">{app.name}</h3>
<StatusBadge status={app.status} /> <StatusBadge status={app.status} />
<CiBadgeInline app={app} />
</div> </div>
<p className="text-gray-400 text-sm mt-1"> <p className="text-gray-400 text-sm mt-1">
{app.description || app.id} {app.description || app.id}

View File

@@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle, XCircle, Clock, Shield } from 'lucide-react';
import { getGiteaWorkflowRuns } from '../utils/api';
import CiRunsModal from './CiRunsModal';
/**
* Badge CI compact affiché dans le header de chaque AppCard.
* Récupère le dernier run CI et affiche un badge coloré.
* Cliquable : ouvre une modale avec l'historique des runs.
*/
export default function CiBadgeInline({ app }) {
const [lastRun, setLastRun] = useState(null);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
const owner = app.giteaOwner || 'manus-admin';
const repo = app.giteaRepo || app.id;
getGiteaWorkflowRuns(owner, repo)
.then((res) => {
const runs = res.data || [];
if (runs.length > 0) setLastRun(runs[0]);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [app.id]);
const handleClick = (e) => {
e.stopPropagation();
setShowModal(true);
};
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-dark-700 text-gray-500 animate-pulse">
<Clock className="w-3 h-3" /> CI
</span>
);
}
let badge;
if (!lastRun) {
badge = (
<span
onClick={handleClick}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-500/15 text-gray-500 border border-gray-500/20 cursor-pointer hover:bg-gray-500/25 transition-colors"
title="Cliquer pour voir l'historique CI"
>
<Shield className="w-3 h-3" /> Pas de CI
</span>
);
} else if (lastRun.conclusion === 'success') {
badge = (
<span
onClick={handleClick}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer hover:bg-emerald-500/25 transition-colors"
title={`CI verte — Run #${lastRun.id} — Cliquer pour l'historique`}
>
<CheckCircle className="w-3 h-3" /> CI
</span>
);
} else if (lastRun.conclusion === 'failure') {
badge = (
<span
onClick={handleClick}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/25 transition-colors"
title={`CI en échec — Run #${lastRun.id} — Cliquer pour l'historique`}
>
<XCircle className="w-3 h-3" /> CI
</span>
);
} else {
badge = (
<span
onClick={handleClick}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse cursor-pointer hover:bg-amber-500/25 transition-colors"
title={`CI en cours — Run #${lastRun.id} — Cliquer pour l'historique`}
>
<Clock className="w-3 h-3" /> CI
</span>
);
}
return (
<>
{badge}
{showModal && <CiRunsModal app={app} onClose={() => setShowModal(false)} />}
</>
);
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { X, CheckCircle, XCircle, Clock, ExternalLink, GitCommit, Calendar, Timer } from 'lucide-react';
import { getGiteaWorkflowRuns } from '../utils/api';
/**
* Formate une durée en secondes en texte lisible.
*/
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return '—';
if (seconds < 60) return `${seconds}s`;
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
}
/**
* Formate une date ISO en format local français.
*/
function formatDate(isoStr) {
if (!isoStr) return '—';
const d = new Date(isoStr);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
/**
* Badge de conclusion d'un run.
*/
function RunStatusBadge({ status, conclusion }) {
if (conclusion === 'success') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
<CheckCircle className="w-3 h-3" /> Succès
</span>
);
}
if (conclusion === 'failure') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
<XCircle className="w-3 h-3" /> Échec
</span>
);
}
if (status === 'running' || status === 'waiting') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse">
<Clock className="w-3 h-3" /> En cours
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
<Clock className="w-3 h-3" /> {conclusion || status || '—'}
</span>
);
}
/**
* Modale affichant l'historique détaillé des derniers runs CI d'une application.
* Ouverte au clic sur un badge CI.
*/
export default function CiRunsModal({ app, onClose }) {
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const owner = app.giteaOwner || 'manus-admin';
const repo = app.giteaRepo || app.id;
getGiteaWorkflowRuns(owner, repo)
.then((res) => {
setRuns(res.data || res || []);
})
.catch(() => setRuns([]))
.finally(() => setLoading(false));
}, [app.id]);
// Fermer avec Escape
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-dark-900 border border-dark-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<div>
<h3 className="text-lg font-semibold text-white">Historique CI</h3>
<p className="text-sm text-gray-400 mt-0.5">{app.name} — {app.giteaRepo || app.id}</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-dark-700 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center gap-3 py-12 text-gray-400">
<Clock className="w-5 h-5 animate-spin" />
Chargement de l'historique...
</div>
) : runs.length === 0 ? (
<div className="text-center py-12 text-gray-500">
Aucun run CI trouvé pour cette application.
</div>
) : (
<div className="space-y-3">
{runs.map((run, idx) => (
<div
key={run.id || idx}
className={`rounded-lg border p-4 transition-colors ${
run.conclusion === 'success'
? 'border-emerald-500/20 bg-emerald-500/5 hover:bg-emerald-500/10'
: run.conclusion === 'failure'
? 'border-red-500/20 bg-red-500/5 hover:bg-red-500/10'
: 'border-dark-600 bg-dark-800/50 hover:bg-dark-800'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-sm font-mono text-gray-500">#{run.id}</span>
<RunStatusBadge status={run.status} conclusion={run.conclusion} />
</div>
{run.url && (
<a
href={run.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors"
>
<ExternalLink className="w-3 h-3" /> Voir sur Gitea
</a>
)}
</div>
<div className="grid grid-cols-3 gap-4 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span>{formatDate(run.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="w-3.5 h-3.5 text-gray-500" />
<span>{formatDuration(run.durationSeconds)}</span>
</div>
<div className="flex items-center gap-1.5">
<GitCommit className="w-3.5 h-3.5 text-gray-500" />
<span className="font-mono">{run.commit ? run.commit.slice(0, 7) : '—'}</span>
</div>
</div>
{run.name && (
<div className="mt-2 text-xs text-gray-500">
Workflow : {run.name}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-dark-700 flex items-center justify-between">
<span className="text-xs text-gray-500">{runs.length} run{runs.length > 1 ? 's' : ''} affichés</span>
<button
onClick={onClose}
className="btn-secondary py-1.5 px-4 text-sm"
>
Fermer
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { LayoutList, TerminalSquare, import { LayoutList, TerminalSquare, ShieldCheck,
Server, Server,
LayoutDashboard, LayoutDashboard,
Box, Box,
@@ -15,6 +15,7 @@ import { LayoutList, TerminalSquare,
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard }, { id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'apps', label: 'Applications', icon: Box }, { id: 'apps', label: 'Applications', icon: Box },
{ id: 'ci', label: 'CI / Qualité', icon: ShieldCheck },
{ id: 'deployments', label: 'Déploiements', icon: ScrollText }, { id: 'deployments', label: 'Déploiements', icon: ScrollText },
{ id: 'gitea', label: 'Gitea', icon: GitBranch }, { id: 'gitea', label: 'Gitea', icon: GitBranch },
{ id: 'docker', label: 'Docker', icon: Container }, { id: 'docker', label: 'Docker', icon: Container },

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw, CheckCircle, XCircle, Clock, TrendingUp, TrendingDown, Minus, Shield, AlertTriangle } from 'lucide-react';
import { getApps, getGiteaWorkflowSummary, getGiteaWorkflowRuns } from '../utils/api';
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return '—';
if (seconds < 60) return `${seconds}s`;
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
}
function TrendIcon({ trend }) {
if (trend === 'faster') return <TrendingDown className="w-4 h-4 text-emerald-400" />;
if (trend === 'slower') return <TrendingUp className="w-4 h-4 text-amber-400" />;
return <Minus className="w-4 h-4 text-gray-500" />;
}
function CiBadge({ conclusion }) {
if (conclusion === 'success') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
<CheckCircle className="w-3 h-3" /> Vert
</span>
);
}
if (conclusion === 'failure') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
<XCircle className="w-3 h-3" /> Échec
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-500/15 text-gray-400 border border-gray-500/30">
<Clock className="w-3 h-3" /> —
</span>
);
}
export default function CiPage({ apps }) {
const [ciData, setCiData] = useState([]);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState(null);
const fetchCiData = async () => {
setLoading(true);
try {
// Pour chaque app qui a un giteaRepo, récupérer le résumé CI et le dernier run
const results = await Promise.all(
(apps || [])
.filter(a => a.giteaRepo || a.id)
.map(async (app) => {
const owner = app.giteaOwner || 'manus-admin';
const repo = app.giteaRepo || app.id;
try {
const [summaryRes, runsRes] = await Promise.all([
getGiteaWorkflowSummary(owner, repo),
getGiteaWorkflowRuns(owner, repo),
]);
const summary = summaryRes.data || {};
const runs = runsRes.data || [];
const lastRun = runs.length > 0 ? runs[0] : null;
return {
appId: app.id,
appName: app.name,
ciRequired: true, // Toutes les apps ont ci.required:true maintenant
lastRun,
summary,
};
} catch (err) {
return {
appId: app.id,
appName: app.name,
ciRequired: false,
lastRun: null,
summary: null,
};
}
})
);
setCiData(results);
setLastRefresh(new Date());
} catch (err) {
console.error('Erreur chargement CI:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (apps && apps.length > 0) {
fetchCiData();
}
}, [apps]);
// Stats globales
const totalApps = ciData.length;
const appsWithCi = ciData.filter(d => d.lastRun);
const appsGreen = ciData.filter(d => d.lastRun?.conclusion === 'success');
const appsFailed = ciData.filter(d => d.lastRun?.conclusion === 'failure');
const globalSuccessRate = appsWithCi.length > 0
? Math.round((appsGreen.length / appsWithCi.length) * 100)
: 0;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<Shield className="w-7 h-7 text-primary-400" />
CI / Qualité
</h2>
<p className="text-gray-400 mt-1">
Gouvernance de l'intégration continue — toutes les applications
</p>
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-gray-500">
Mis à jour : {lastRefresh.toLocaleTimeString('fr-FR')}
</span>
)}
<button
onClick={fetchCiData}
disabled={loading}
className="btn-secondary py-2 px-4 text-sm flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</button>
</div>
</div>
{/* KPIs globaux */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="card p-5">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Applications CI</span>
<Shield className="w-5 h-5 text-primary-400" />
</div>
<p className="text-3xl font-bold text-white mt-2">{appsWithCi.length}<span className="text-lg text-gray-500">/{totalApps}</span></p>
<p className="text-xs text-gray-500 mt-1">Avec workflow actif</p>
</div>
<div className="card p-5">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Taux de succès</span>
<CheckCircle className="w-5 h-5 text-emerald-400" />
</div>
<p className={`text-3xl font-bold mt-2 ${globalSuccessRate >= 80 ? 'text-emerald-400' : globalSuccessRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
{globalSuccessRate}%
</p>
<p className="text-xs text-gray-500 mt-1">{appsGreen.length} vertes / {appsFailed.length} en échec</p>
</div>
<div className="card p-5">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">CI verte</span>
<CheckCircle className="w-5 h-5 text-emerald-400" />
</div>
<p className="text-3xl font-bold text-emerald-400 mt-2">{appsGreen.length}</p>
<p className="text-xs text-gray-500 mt-1">Déploiement autorisé</p>
</div>
<div className="card p-5">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">CI en échec</span>
<XCircle className="w-5 h-5 text-red-400" />
</div>
<p className="text-3xl font-bold text-red-400 mt-2">{appsFailed.length}</p>
<p className="text-xs text-gray-500 mt-1">Déploiement bloqué</p>
</div>
</div>
{/* Tableau détaillé */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-dark-700">
<h3 className="text-lg font-semibold text-white">Détail par application</h3>
</div>
{loading ? (
<div className="p-8 flex items-center justify-center gap-3 text-gray-400">
<RefreshCw className="w-5 h-5 animate-spin" />
Chargement des données CI...
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-700 bg-dark-800/50">
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase">Application</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">CI requise</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Dernier run</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Taux succès</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Durée médiane</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Tendance</th>
<th className="text-center px-4 py-3 text-xs font-medium text-gray-500 uppercase">Garde-fou</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{ciData.map((item) => (
<tr key={item.appId} className="hover:bg-dark-800/30 transition-colors">
<td className="px-6 py-4">
<span className="font-medium text-white">{item.appName}</span>
<span className="text-xs text-gray-500 ml-2">{item.appId}</span>
</td>
<td className="text-center px-4 py-4">
{item.ciRequired ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-primary-500/15 text-primary-400 border border-primary-500/30">
<Shield className="w-3 h-3" /> Oui
</span>
) : (
<span className="text-gray-600 text-xs">Non</span>
)}
</td>
<td className="text-center px-4 py-4">
<CiBadge conclusion={item.lastRun?.conclusion} />
</td>
<td className="text-center px-4 py-4">
{item.summary?.successRate != null ? (
<span className={`font-semibold ${item.summary.successRate >= 80 ? 'text-emerald-400' : item.summary.successRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
{item.summary.successRate}%
</span>
) : (
<span className="text-gray-600">—</span>
)}
</td>
<td className="text-center px-4 py-4">
<span className="text-gray-300">
{formatDuration(item.summary?.medianDurationSeconds)}
</span>
</td>
<td className="text-center px-4 py-4">
<div className="flex items-center justify-center gap-1">
<TrendIcon trend={item.summary?.trend} />
{item.summary?.changePercent != null && (
<span className={`text-xs ${item.summary.changePercent <= -10 ? 'text-emerald-400' : item.summary.changePercent >= 10 ? 'text-amber-400' : 'text-gray-500'}`}>
{item.summary.changePercent > 0 ? '+' : ''}{item.summary.changePercent}%
</span>
)}
</div>
</td>
<td className="text-center px-4 py-4">
{item.lastRun?.conclusion === 'success' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
<CheckCircle className="w-3 h-3" /> Autorisé
</span>
) : item.lastRun?.conclusion === 'failure' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/30">
<AlertTriangle className="w-3 h-3" /> Bloqué
</span>
) : (
<span className="text-gray-600 text-xs">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}