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 InventairePage from './pages/InventairePage';
|
||||
import TerminalPage from './pages/TerminalPage';
|
||||
import CiPage from './pages/CiPage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import CiAlertBanner from './components/CiAlertBanner';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
@@ -116,6 +117,8 @@ export default function App() {
|
||||
return <DashboardPage apps={apps} />;
|
||||
case 'apps':
|
||||
return <AppsPage apps={apps} onRefresh={fetchApps} />;
|
||||
case 'ci':
|
||||
return <CiPage apps={apps} />;
|
||||
case 'deployments':
|
||||
return <DeploymentsPage />;
|
||||
case 'gitea':
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import CiBadgeInline from './CiBadgeInline';
|
||||
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@@ -137,6 +138,7 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{app.name}</h3>
|
||||
<StatusBadge status={app.status} />
|
||||
<CiBadgeInline app={app} />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{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 { LayoutList, TerminalSquare,
|
||||
import { LayoutList, TerminalSquare, ShieldCheck,
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
@@ -15,6 +15,7 @@ import { LayoutList, TerminalSquare,
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'apps', label: 'Applications', icon: Box },
|
||||
{ id: 'ci', label: 'CI / Qualité', icon: ShieldCheck },
|
||||
{ id: 'deployments', label: 'Déploiements', icon: ScrollText },
|
||||
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
|
||||
{ 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