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) => (
+
+
+
+
+
+
+ {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...
+
+ ) : (
+
+
+
+
+ | Application |
+ CI requise |
+ Dernier run |
+ Taux succès |
+ Durée médiane |
+ Tendance |
+ Garde-fou |
+
+
+
+ {ciData.map((item) => (
+
+ |
+ {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é
+
+ ) : (
+ —
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}