Sécuriser Gitea et alerter sur les échecs CI

This commit is contained in:
Manus Déploiement Santinova
2026-08-21 16:16:46 +00:00
parent 1f1d525ea1
commit a37e29e42a
27 changed files with 819 additions and 74 deletions

View File

@@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage';
import InventairePage from './pages/InventairePage';
import TerminalPage from './pages/TerminalPage';
import Sidebar from './components/Sidebar';
import CiAlertBanner from './components/CiAlertBanner';
import useWebSocket from './hooks/useWebSocket';
import { getMe, getApps, logout as apiLogout } from './utils/api';
import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api';
export default function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState('dashboard');
const [apps, setApps] = useState([]);
const [ciAlerts, setCiAlerts] = useState([]);
// Vérifier l'authentification au chargement
useEffect(() => {
const token = localStorage.getItem('dashboard_token');
@@ -25,6 +27,7 @@ export default function App() {
.then(() => {
setAuthenticated(true);
fetchApps();
fetchCiAlerts();
})
.catch(() => {
localStorage.removeItem('dashboard_token');
@@ -43,11 +46,22 @@ export default function App() {
console.error('Erreur chargement apps:', err);
}
};
const fetchCiAlerts = async () => {
try {
const response = await getCiAlerts();
setCiAlerts(response.data);
} catch (error) {
console.error('Erreur chargement alertes CI:', error);
}
};
// WebSocket handler
const handleWsMessage = useCallback((data) => {
if (data.type === 'health_update') {
setApps(data.data);
}
if (data.type === 'ci_alert') {
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
}
}, []);
const { connected: wsConnected } = useWebSocket(
authenticated ? handleWsMessage : null
@@ -55,6 +69,7 @@ export default function App() {
const handleLogin = (data) => {
setAuthenticated(true);
fetchApps();
fetchCiAlerts();
};
const handleLogout = async () => {
try {
@@ -65,6 +80,7 @@ export default function App() {
localStorage.removeItem('dashboard_token');
setAuthenticated(false);
setApps([]);
setCiAlerts([]);
};
if (loading) {
return (
@@ -126,7 +142,10 @@ export default function App() {
onLogout={handleLogout}
wsConnected={wsConnected}
/>
<main className="ml-64 p-8">{renderPage()}</main>
<main className="ml-64 p-8">
<CiAlertBanner alert={ciAlerts[0]} />
{renderPage()}
</main>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { AlertTriangle, ExternalLink } from 'lucide-react';
export default function CiAlertBanner({ alert }) {
if (!alert) return null;
return (
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-100 shadow-lg shadow-red-950/20" role="alert">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
<div>
<p className="font-semibold">Échec CI — {alert.repository}</p>
<p className="mt-1 text-sm text-red-100/80">{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}</p>
<p className="mt-1 text-xs text-red-100/60">Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}</p>
</div>
</div>
{alert.url && (
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary flex shrink-0 items-center gap-2">
Voir le workflow <ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
);
}

View File

@@ -15,8 +15,10 @@ import {
TrendingDown,
TrendingUp,
Minus,
KeyRound,
ShieldCheck,
} from 'lucide-react';
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
@@ -54,6 +56,34 @@ export default function GiteaPage() {
const [workflowRuns, setWorkflowRuns] = useState([]);
const [workflowSummary, setWorkflowSummary] = useState(null);
const [loadingCommits, setLoadingCommits] = useState(false);
const [rotation, setRotation] = useState(null);
const [rotationBusy, setRotationBusy] = useState(false);
const [rotationError, setRotationError] = useState('');
const fetchRotation = async () => {
try {
const response = await getGiteaTokenRotation();
setRotation(response.data);
} catch (error) {
setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.');
}
};
const runRotationAction = async (action) => {
setRotationBusy(true);
setRotationError('');
try {
const response = action === 'prepare'
? await prepareGiteaTokenRotation()
: await approveGiteaTokenRotation();
setRotation(response.data);
} catch (error) {
setRotationError(error.response?.data?.error || 'Opération de rotation impossible.');
if (error.response?.data?.status) setRotation(error.response.data.status);
} finally {
setRotationBusy(false);
}
};
const fetchRepos = async () => {
setLoading(true);
@@ -92,6 +122,7 @@ export default function GiteaPage() {
useEffect(() => {
fetchRepos();
fetchRotation();
}, []);
return (
@@ -124,6 +155,31 @@ export default function GiteaPage() {
</button>
</div>
<div className="card p-5 border border-primary-500/20 bg-primary-500/5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-primary-500/10 p-2"><KeyRound className="h-5 w-5 text-primary-300" /></div>
<div>
<h3 className="font-semibold text-white">Rotation contrôlée du jeton Gitea</h3>
<p className="mt-1 text-sm text-gray-400">{rotation?.message || 'Chargement du statut…'}</p>
{rotation?.tokenName && <p className="mt-1 text-xs font-mono text-gray-500">{rotation.tokenName}</p>}
{rotation?.updatedAt && <p className="mt-1 text-xs text-gray-500">Mis à jour : {formatDate(rotation.updatedAt)}</p>}
{rotationError && <p className="mt-2 text-sm text-red-300">{rotationError}</p>}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button disabled={rotationBusy} onClick={() => runRotationAction('prepare')} className="btn-secondary flex items-center gap-2 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${rotationBusy ? 'animate-spin' : ''}`} /> Préparer maintenant
</button>
{rotation?.state === 'pending_approval' && (
<button disabled={rotationBusy} onClick={() => runRotationAction('approve')} className="btn-primary flex items-center gap-2 disabled:opacity-50">
<ShieldCheck className="h-4 w-4" /> Approuver le basculement
</button>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Repos List */}
<div className="space-y-3">

View File

@@ -54,6 +54,7 @@ export const createApp = (formData) =>
// Deployments
export const getDeployments = (appId) =>
api.get('/deployments', { params: { appId } });
export const getCiAlerts = () => api.get('/ci-alerts');
// Gitea
export const getGiteaRepos = () => api.get('/gitea/repos');
@@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
export const getGiteaWorkflowSummary = (owner, repo) =>
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation');
export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare');
export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve');
// Docker
export const getContainers = () => api.get('/docker/containers');