diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index d6b2a62..1d27bf9 100644 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -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 ; case 'apps': return ; + case 'ci': + return ; case 'deployments': return ; case 'gitea': diff --git a/src/frontend/src/components/AppCard.jsx b/src/frontend/src/components/AppCard.jsx index 74276f1..efaf3b3 100644 --- a/src/frontend/src/components/AppCard.jsx +++ b/src/frontend/src/components/AppCard.jsx @@ -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 }) {

{app.name}

+

{app.description || app.id} diff --git a/src/frontend/src/components/CiBadgeInline.jsx b/src/frontend/src/components/CiBadgeInline.jsx new file mode 100644 index 0000000..57c00cf --- /dev/null +++ b/src/frontend/src/components/CiBadgeInline.jsx @@ -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 ( + + CI + + ); + } + + let badge; + + if (!lastRun) { + badge = ( + + Pas de CI + + ); + } else if (lastRun.conclusion === 'success') { + badge = ( + + CI + + ); + } else if (lastRun.conclusion === 'failure') { + badge = ( + + CI + + ); + } else { + badge = ( + + CI + + ); + } + + return ( + <> + {badge} + {showModal && setShowModal(false)} />} + + ); +} diff --git a/src/frontend/src/components/CiRunsModal.jsx b/src/frontend/src/components/CiRunsModal.jsx new file mode 100644 index 0000000..be6ec0a --- /dev/null +++ b/src/frontend/src/components/CiRunsModal.jsx @@ -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 ( + + Succès + + ); + } + if (conclusion === 'failure') { + return ( + + Échec + + ); + } + if (status === 'running' || status === 'waiting') { + return ( + + En cours + + ); + } + return ( + + {conclusion || status || '—'} + + ); +} + +/** + * 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 ( +

+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

Historique CI

+

{app.name} — {app.giteaRepo || app.id}

+
+ +
+ + {/* Body */} +
+ {loading ? ( +
+ + Chargement de l'historique... +
+ ) : runs.length === 0 ? ( +
+ Aucun run CI trouvé pour cette application. +
+ ) : ( +
+ {runs.map((run, idx) => ( +
+
+
+ #{run.id} + +
+ {run.url && ( + + Voir sur Gitea + + )} +
+ +
+
+ + {formatDate(run.createdAt)} +
+
+ + {formatDuration(run.durationSeconds)} +
+
+ + {run.commit ? run.commit.slice(0, 7) : '—'} +
+
+ + {run.name && ( +
+ Workflow : {run.name} +
+ )} +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ {runs.length} run{runs.length > 1 ? 's' : ''} affichés + +
+
+
+ ); +} diff --git a/src/frontend/src/components/Sidebar.jsx b/src/frontend/src/components/Sidebar.jsx index 7e5255e..199c992 100644 --- a/src/frontend/src/components/Sidebar.jsx +++ b/src/frontend/src/components/Sidebar.jsx @@ -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 }, diff --git a/src/frontend/src/pages/CiPage.jsx b/src/frontend/src/pages/CiPage.jsx new file mode 100644 index 0000000..afed9de --- /dev/null +++ b/src/frontend/src/pages/CiPage.jsx @@ -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 ; + if (trend === 'slower') return ; + return ; +} + +function CiBadge({ conclusion }) { + if (conclusion === 'success') { + return ( + + Vert + + ); + } + if (conclusion === 'failure') { + return ( + + Échec + + ); + } + return ( + + — + + ); +} + +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 ( +
+ {/* Header */} +
+
+

+ + CI / Qualité +

+

+ Gouvernance de l'intégration continue — toutes les applications +

+
+
+ {lastRefresh && ( + + Mis à jour : {lastRefresh.toLocaleTimeString('fr-FR')} + + )} + +
+
+ + {/* KPIs globaux */} +
+
+
+ Applications CI + +
+

{appsWithCi.length}/{totalApps}

+

Avec workflow actif

+
+ +
+
+ Taux de succès + +
+

= 80 ? 'text-emerald-400' : globalSuccessRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}> + {globalSuccessRate}% +

+

{appsGreen.length} vertes / {appsFailed.length} en échec

+
+ +
+
+ CI verte + +
+

{appsGreen.length}

+

Déploiement autorisé

+
+ +
+
+ CI en échec + +
+

{appsFailed.length}

+

Déploiement bloqué

+
+
+ + {/* Tableau détaillé */} +
+
+

Détail par application

+
+ {loading ? ( +
+ + Chargement des données CI... +
+ ) : ( +
+ + + + + + + + + + + + + + {ciData.map((item) => ( + + + + + + + + + + ))} + +
ApplicationCI requiseDernier runTaux succèsDurée médianeTendanceGarde-fou
+ {item.appName} + {item.appId} + + {item.ciRequired ? ( + + Oui + + ) : ( + Non + )} + + + + {item.summary?.successRate != null ? ( + = 80 ? 'text-emerald-400' : item.summary.successRate >= 50 ? 'text-amber-400' : 'text-red-400'}`}> + {item.summary.successRate}% + + ) : ( + — + )} + + + {formatDuration(item.summary?.medianDurationSeconds)} + + +
+ + {item.summary?.changePercent != null && ( + = 10 ? 'text-amber-400' : 'text-gray-500'}`}> + {item.summary.changePercent > 0 ? '+' : ''}{item.summary.changePercent}% + + )} +
+
+ {item.lastRun?.conclusion === 'success' ? ( + + Autorisé + + ) : item.lastRun?.conclusion === 'failure' ? ( + + Bloqué + + ) : ( + — + )} +
+
+ )} +
+
+ ); +}