Sécuriser Gitea et alerter sur les échecs CI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
24
src/frontend/src/components/CiAlertBanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user