Préserver le tableau de bord CI existant
This commit is contained in:
@@ -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':
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
91
src/frontend/src/components/CiBadgeInline.jsx
Normal file
91
src/frontend/src/components/CiBadgeInline.jsx
Normal 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)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/frontend/src/components/CiRunsModal.jsx
Normal file
185
src/frontend/src/components/CiRunsModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
266
src/frontend/src/pages/CiPage.jsx
Normal file
266
src/frontend/src/pages/CiPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user